Skip to content

Add Svelte support: @saykit/svelte integration + @saykit/transform-svelte transformer #41

Description

@k0d13

Summary

Add first-class Svelte (and, downstream, SvelteKit) support to SayKit, mirroring what we ship
for React via @saykit/react and
@saykit/transform-jsx, and what's proposed for Vue in #40. This
means a runtime integration package plus a source transformer that understands Svelte's
authoring model.

Proposed new packages:

  • @saykit/svelte — the runtime integration (the Svelte analogue of @saykit/react):
    a <Say> component, a getSay() accessor built on Svelte context, a provider that seeds
    that context, and a Svelte-flavoured renderer.
  • @saykit/transform-svelte — the compile-time transformer (the analogue of
    @saykit/transform-jsx): parses .svelte components, extracts messages from the markup and
    <script> blocks, and rewrites the macros into runtime <Say> calls.
  • (later) @saykit/sveltekit — SSR + hooks/load integration, analogous to the React
    server helpers and the proposed @saykit/nuxt. Out of scope for the first pass but worth
    designing toward.

I've never written (or even read) Svelte, so a lot of this is "here's the shape I think it
takes, please sanity-check." Below is what I've been able to work out about how Svelte's
tooling maps onto our existing architecture. This targets Svelte 5 (runes + snippets).


How our architecture works today (for reference)

The pipeline has a clean split we want to preserve:

  1. A Transformer (packages/config/src/shapes.ts)
    is a pure source-to-source object: { match(id), extract(code, id), transform(code, id) }.
    extract returns ICU messages for the CLI to write into catalogues; transform rewrites
    the authored macros (say`...`, <Say>...</Say>) into runtime calls that read from
    the compiled catalogue. This is used both by the CLI (saykit extract) and by the
    bundler plugins.
  2. @saykit/transform-jsx implements that interface with Babel: @babel/parser builds
    a JSX/TSX AST, @babel/traverse walks it looking for say tagged templates and <Say>
    JSX elements, and @babel/generator prints the rewritten program back out. Text +
    interpolations + nested elements get folded into our CompositeMessage model
    (ArgumentMessage, ElementMessage, ChoiceMessage).
  3. @saykit/react is the runtime: <Say> is a macro that resolves to GET_SAY().call(descriptor),
    producing an ICU-rendered string with placeholder tags like <0>...</0>, which a
    framework-agnostic Renderer re-inflates into React elements (mapping tag indices back to
    the elements/components passed in). SayProvider + useSay distribute the Say instance
    via React context.
  4. unplugin-saykit (enforce: 'pre') calls bucket.transformer.transform(code, id) on each
    matched file, and injects the compiled catalogue at load time.

The two Svelte questions that matter: does Svelte give us an AST we can parse/rewrite the way
Babel does for JSX, and can a Vite plugin get in front of Svelte's own compilation?
Short
answer to both: yes — and on the second point Svelte is actually better set up than Vue.


Does Svelte have a Babel-equivalent parser/generator?

Yes for parsing, "no" for generation (same story as Vue). A .svelte file isn't a single JS
grammar — it's <script> / <script module> blocks plus markup ({expr} interpolation,
{#if} / {#each} / {@html} blocks, components) plus <style>.

  • svelte/compiler — the official compiler exports parse(), which returns a Svelte AST
    covering the script blocks and the markup, with source start/end offsets on every node.
    This is the entry point for @saykit/transform-svelte and is the direct analogue of
    @babel/parser.
  • <script> / <script module> — these are just JS/TS. We can very likely reuse
    @saykit/transform-js
    almost verbatim for the say`...` tagged-template macro inside
    the script block, exactly as proposed for Vue's <script setup>. That part is close to free.
  • Markup — Svelte's own grammar. The AST gives us element/component nodes and
    {expression} interpolation nodes (single braces, unlike Vue's {{ }}), so we can find
    <Say> elements and interpolations and fold them onto our CompositeMessage model just like
    the JSX parser does.

The catch (identical to Vue): Svelte's compiler compiles down to JS; there's no
@babel/generator equivalent that round-trips markup AST → .svelte source. So transform
needs to be surgical string splicing over the original source using the node start/end
offsets — a MagicString-style approach. The good news is this is idiomatic in Svelte:
Svelte's compiler and its preprocessors already use MagicString and return { code, map },
so we're working with the grain of the ecosystem rather than against it.

Can a Vite plugin modify Svelte stuff? (Svelte is nicer here than Vue)

Yes, and Svelte gives us a first-class, officially-supported hook that Vue lacks:

  1. The Svelte preprocessor API. svelte/compiler exposes preprocess(source, preprocessors),
    and svelte.config.js has a preprocess field. A preprocessor is { markup, script, style }
    hooks that transform the raw source before Svelte compiles it, returning { code, map }.
    This is exactly the shape of our Transformer.transform, so @saykit/transform-svelte can be
    exposed both as a saykit Transformer (for the CLI + unplugin) and as a drop-in Svelte
    preprocessor for users who'd rather wire it into svelte.config.js. This is cleaner than the
    Vue situation, where we had to choose between raw-source transforms and compiler node-transforms.
  2. Raw-source transform via unplugin. Independently, our unplugin already runs
    enforce: 'pre', so @saykit/transform-svelte's transform runs on the raw .svelte source
    before @sveltejs/vite-plugin-svelte compiles it. The CLI extract/transform path uses the
    same transformer standalone.

Either way, unplugin-saykit just needs @saykit/transform-svelte registered as a bucket
transformer that matches .svelte; no plugin-core changes should be required.


What would the SayKit API look like in Svelte?

Rough sketch — feedback very welcome from anyone who actually writes Svelte.

Config — same shape as today, just a new transformer:

import { defineConfig } from '@saykit/config';
import po from '@saykit/format-po';
import svelte from '@saykit/transform-svelte';

export default defineConfig({
  locales: ['en', 'fr'],
  buckets: [
    { include: ['src/**/*.svelte'], output: 'src/locales/{locale}.{extension}',
      formatter: po(), transformer: svelte() },
  ],
});

App setup — you construct the core Say instance yourself (the normal new Say(...)
idiom), then seed it into Svelte's context so descendants can read it. Svelte's context is
component-scoped and set during init, so this is a small provider component (or a helper that
calls setContext) placed at the root — in SvelteKit, your +layout.svelte:

<script lang="ts">
  import { Say } from 'saykit';
  import { SayProvider } from '@saykit/svelte';
  import en from './locales/en.po';

  const say = new Say({ locales: ['en', 'fr'], messages: { en } });
  say.activate('en');
</script>

<SayProvider {say}>
  {@render children()}
</SayProvider>

This is deliberately bring-your-own-instance, matching our React server helper
(setSay(say) in packages/integration-react/src/runtime/server.ts), which already takes a
real Say and clones+freezes it. The React client SayProvider is the odd one out — it
takes plain locale/messages props and news up the instance internally, but only because
those props must cross the React Server Components serialization boundary. Svelte has no such
boundary, so there's no reason to hide the constructor.

The Say instance is not reactive, and that's fine — it isn't in React either. Switching
locale works the same way: swap the provided instance (re-activate and re-provide) rather than
expecting fine-grained reactivity from Say itself.

Tagged template in <script> (reuses @saykit/transform-js semantics):

<script lang="ts">
  import { getSay } from '@saykit/svelte';
  const say = getSay();
  const greeting = say`Hello, ${name}!`;
</script>

<Say> in markup — the interesting bit. Text, { } interpolations, and nested
elements/components fold into one message; nested elements likely map to Svelte snippets
(Svelte 5's replacement for slots), the way JSX children map to React elements in our renderer:

<Say>Hello {name}! Read the <a href={url}>docs</a>.</Say>

<Say.Plural _={count} one="You have 1 item" other="You have # items" />

(<Say.Plural> relies on Svelte's dotted/namespaced component syntax, which it supports.)

getSay() is the Svelte analogue of the React useSay hook — it getContext()s the
instance seeded by SayProvider, throwing if no provider is above it in the tree.

Renderer — the hard part, and where Svelte differs most from React/Vue. The ICU output
still comes back as a string with <0>...</0> placeholder tags, and we need to inflate that
into real nodes at runtime. React and Vue both have a runtime element factory for this
(createElement / h()), so packages/integration-react/src/components/renderer.ts can build
an arbitrary tree from a parsed string on the fly. Svelte is compile-first and has no general
runtime h()
, so dynamic string → tree inflation is genuinely harder. Likely building blocks:
<svelte:element this={tag}> for dynamic elements and {@render snippet()} to splice in the
caller-supplied elements/components. This renderer is the main piece of design risk in the whole
integration and is worth prototyping first.


Open questions / decisions to make

  • Runtime renderer strategy: confirm whether <svelte:element> + snippets can reconstruct
    the <0>...</0> placeholder tree at runtime, and how expensive/ergonomic that is versus
    React/Vue. This is the biggest unknown.
  • Markup rewriting strategy: confirm the MagicString-over-offsets approach for the markup
    block, since Svelte has no markup generator to round-trip through (same as Vue).
  • Preprocessor vs. unplugin: ship @saykit/transform-svelte as both a saykit Transformer
    and a Svelte preprocessor? I lean yes, since the preprocessor form is what Svelte users expect.
  • Snippets vs. slots for embedded elements: confirm named snippets are the right mapping for
    our ElementMessage children (<a>docs</a> inside a <Say>).
  • <Say> component vs. an action/attribute form: a use:say action might be natural for
    text-only or attribute cases; the component covers rich/nested content.
  • <script module> reuse: confirm @saykit/transform-js can be dropped in for both the
    instance and module script blocks without runes-specific surprises.
  • SvelteKit / SSR: design @saykit/svelte so a later @saykit/sveltekit (locale in
    hooks.server, load-based hydration, root-layout provider) drops in without breaking
    changes, mirroring @saykit/react's server helpers.
  • New repo labels: we'll want package: svelte and package: transform-svelte to match the
    existing per-package label convention in .github/labels.yml.

Suggested rollout

  1. Prototype the runtime renderer (<svelte:element> + snippets) to de-risk the hardest part.
  2. @saykit/transform-svelte — .svelte parsing + extraction first (unlocks saykit extract
    for .svelte), then transform; expose as both a Transformer and a Svelte preprocessor.
  3. @saykit/svelte — runtime <Say>, getSay, SayProvider, Svelte renderer.
  4. An examples/svelte (Vite + @sveltejs/vite-plugin-svelte + unplugin-saykit) end-to-end
    example and integration docs on the website.
  5. @saykit/sveltekit for SSR as a follow-up.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    requestA request for a new feature or a change in behaviour

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions