Skip to content

feat: Svelte 5 adapter (@formepdf/svelte) + framework-neutral @formepdf/shared core#21

Open
cmjoseph07 wants to merge 18 commits into
danmolitor:mainfrom
cmjoseph07:svelte-adapter
Open

feat: Svelte 5 adapter (@formepdf/svelte) + framework-neutral @formepdf/shared core#21
cmjoseph07 wants to merge 18 commits into
danmolitor:mainfrom
cmjoseph07:svelte-adapter

Conversation

@cmjoseph07

Copy link
Copy Markdown

feat: Svelte 5 adapter (@formepdf/svelte) + framework-neutral @formepdf/shared core

Adds a first-class Svelte 5 authoring adapter with the full component set, plus the refactor that makes multi-framework adapters sustainable: a framework-neutral @formepdf/shared package extracted from @formepdf/react.

What's in here

@formepdf/shared (new)

The framework-neutral serialization core: document-model (Forme*) types, Style mapping and CSS shorthand/color/edge/grid/transform parsing, the Font registration store, the Canvas operation recorder, chart kind builders, and semantic-component constants (heading defaults, inline-formatting defaults, list marker mapping).

@formepdf/react re-exports everything it previously exported - its public API is unchanged (all 215 react tests pass, including the 35 added in 0.10.5 for headings/lists/inline formatting/transforms, which now exercise the shared code paths).

@formepdf/svelte (new)

The same components with the same props as @formepdf/react, authored as ordinary .svelte files:

  • Layout: Document, Page, View, Text, Image, Fixed, PageBreak
  • Semantics: H1-H6, OrderedList/UnorderedList/ListItem, Strong/Em/Code/Link (the 0.10.5 additions, at parity from day one)
  • Tables: Table, Row, Cell
  • Graphics: Svg, QrCode, Barcode, Canvas, Watermark
  • Charts: BarChart, LineChart, PieChart, AreaChart, DotPlot
  • Forms: TextField, Checkbox, Dropdown, RadioButton

Plus: renderDocument()/renderDocumentWithLayout() over the optional @formepdf/core peer, a formePreview() SvelteKit route helper (live preview with layout overlays and click-to-inspect), PAGE_NUMBER/TOTAL_PAGES constants, Font/StyleSheet parity, and tw() support.

How it works

Svelte components can't be walked like a React element tree, so each component SSR-renders a namespaced placeholder tag (<forme-view props="...">) carrying its JSON-encoded props. serialize() renders the template on the server and a parse5-based parser converts the markup into the identical document-model JSON the react adapter produces. The placeholder vocabulary is an internal contract between the components and the parser inside this package, documented as such.

{#each}, {#if}, snippets, and interpolation just work - templates are plain Svelte 5 components with no special template language. Whitespace is normalized to JSX-equivalent semantics so .svelte and .tsx twins serialize identically.

Drift protection

  • Parity suite: 12 fixture pairs authored in both .svelte and .tsx, asserted deep-equal after normalizing adjacent same-style text runs (react splits runs at JSX child boundaries, e.g. <Strong>${'$'}{price}.00</Strong> is three string children; Svelte's SSR merges contiguous text before the parser ever sees it - adjacent runs with identical style/href render identically, and runs with different styles are never merged, so real drift still fails).
  • Shared kind builders: chart prop mapping and semantic-component defaults live in @formepdf/shared and are consumed by both adapters, so they cannot drift by construction.
  • WASM smoke tests: fixtures render to real PDF bytes through @formepdf/core (engine 0.10.5), asserting page counts, header repetition, roman list markers, transform matrices, embedded fonts, and page-number substitution in content streams.
  • Docs samples: every code sample in docs/svelte.mdx and the README is a fixture compiled and serialized in tests.

Scope notes

  • Templates stay TSX-only: the hosted-API expression system depends on the recording proxy, which has no Svelte equivalent. Documented in the changelog and docs.
  • No sourceLocation: SSR markup carries no source positions, so click-to-inspect maps to elements, not .svelte source lines.
  • CI: shared and svelte build/svelte-check/test steps added to the TypeScript job.
  • Changelogs: per-package entries under [Unreleased] (shared and svelte initial entries, react extraction note); versions aligned with the 0.10.5 monorepo line for the maintainer to stamp at release.

Test results

Suite Result
react 215 passed
svelte 173 passed (parity, parser, encode, preview, renderDocument, docs samples, WASM smoke)
svelte-check 0 errors, 0 warnings
core / renderer / cli / hono / next / resend / mcp 17 / 18 / 3 / 8 / 8 / 5 / 78 passed

Nested <Text> children serialize to styled runs matching the react
adapter's output exactly, including boundary whitespace and deep-nesting
flattening. New Fixed component emits header/footer nodes. PAGE_NUMBER /
TOTAL_PAGES constants are the documented way to emit page-number
placeholders, since {{pageNumber}} parses as a Svelte expression.
The table trio serializes with full prop fidelity: Table columns
(fraction / fixed / auto widths via shared mapColumnWidth), Row header
flag driving repeat-on-continuation, and Cell colSpan / rowSpan with
arbitrary nested content. Nesting is validated with the same error
messages as the react adapter. Parity fixture: a 50-row {#each} table
deep-equal against its TSX twin, smoke-rendered to a multi-page PDF
with the header row repeated on every page.
Four media leaf components serializing to their document-model node
kinds, mirroring the react adapter: Image passes src through unresolved
(data URI or file path - resolution stays a rendering concern), Svg
takes a content markup string with viewBox, QrCode and Barcode map
their color prop into style.color. alt and href pass through on Image
and Svg.

encodeProps now accepts any object (interfaces lack index signatures)
and decodeProps returns unknown so parser-side prop interfaces can
declare required fields.

Covered by parser unit tests (src byte-identity, svg content
round-trip, engine defaults), cross-adapter parity fixtures, and a
combined WASM smoke render.
Canvas executes its draw callback at emit time against the shared
CanvasContext recorder, so the one function-valued prop
becomes an attribute-safe operation list. Watermark mirrors react's
color-alpha-to-opacity mapping; PageBreak is a bare leaf. Parity
fixtures cover all three, plus a two-page WASM smoke render.
Add the five engine-native chart components (BarChart, LineChart,
PieChart, AreaChart, DotPlot) to the svelte adapter.

The camelCase-to-snake_case kind mapping and chart prop types move
from the react adapter into @formepdf/shared (charts.ts) so both
adapters serialize charts through the same builders and cannot drift.
React re-exports the moved prop types and its serializers now call
the shared builders - behavior-neutral, proven by its untouched suite.

The svelte parser handles all five placeholder tags through one
generic parseChart that delegates to the shared builders and maps
style itself.

Covered by parser unit tests (snake_case mapping, defaults, per-datum
colors, axis bounds), a dashboard-style parity fixture pair exercising
multi-series, grouped, and minimal-props variants of every chart type,
and a WASM smoke render asserting each chart's drawn output.
TextField, Checkbox, Dropdown, and RadioButton serialize to their
AcroForm node kinds with the same defaults and conditional props as
the react adapter. Covered by parser tests, cross-adapter parity
fixtures (registration form), and a WASM smoke test asserting the
rendered PDF contains the AcroForm field dictionaries.
- Re-export Font from the shared store singleton; <Document fonts>
  merges with globals, document fonts winning on family:weight:italic
- Tunnel Uint8Array font sources through a base64 marker in the props
  attribute so byte sources survive serialization as in react;
  reserved-key collisions fail loudly at encode time
- Add fonts and tailwind parity fixtures, font merge/override tests,
  and a WASM smoke render embedding NotoSans
- renderDocument/renderDocumentWithLayout serialize a template and
  render via a bare dynamic import('@formepdf/core') so core's
  conditional exports pick the runtime entry; core is an optional
  peer (^0.10.4) and a missing install rejects with an actionable
  error naming the install command
- render options (embedData, flattenForms) forward to core untouched,
  matching react-path semantics
- core: add renderSerializedDoc/renderSerializedDocWithLayout to the
  Node entry for parity with the browser/worker entries; the
  react-facing renderDocument pair now delegates to them
formePreview() serves the copied renderer preview UI from a catch-all
route: prefix-relative pdf/layout endpoints and a version-polling
reload replacing the WebSocket channel. Preview HTML is
embedded at build time as a generated module; build fails loudly when
the asset is missing.
Ports the 0.10.5 react additions (H1-H6, OrderedList/UnorderedList/
ListItem, Strong/Em/Code/Link, CSS transform styles) to the Svelte
adapter. The framework-neutral pieces (heading and inline-formatting
default styles, list marker mapping, transform parsing) live in
@formepdf/shared so the adapters cannot drift.

The parser's run builder is now recursive with style/href accumulation,
matching react's buildTextRuns cascade semantics. Parity tests compare
run-normalized JSON: react splits runs at JSX child boundaries, Svelte's
SSR merges contiguous text before the parser sees it, and adjacent
same-style runs render identically.
…coverage

- Page components in both adapters now type the style prop the
  serializers already handled
- formePreview 503 responses return a generic message in production
  instead of the raw render error (dev keeps the full message - the
  preview page shows template errors in the browser)
- encode.ts documents why undefined JSON drops a prop (functions have
  no JSON representation and must not emit invalid JSON)
- CI builds, svelte-checks, and tests @formepdf/svelte
renderDocument returned Promise<Uint8Array<ArrayBufferLike>>, which the
DOM BodyInit rejects - new Response(pdf) failed typecheck in a fresh
SvelteKit app, including the README quickstart verbatim. The WASM
bridge always returns an ArrayBuffer-backed view, so narrow the return
types (renderDocument, and RenderDocumentWithLayoutResult for the
WithLayout variant) instead of making every endpoint cast.

Found by user-perspective E2E against packed tarballs.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant