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:
- 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.
@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).
@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.
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:
- 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.
- 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
- Prototype the runtime renderer (
<svelte:element> + snippets) to de-risk the hardest part.
@saykit/transform-svelte — .svelte parsing + extraction first (unlocks saykit extract
for .svelte), then transform; expose as both a Transformer and a Svelte preprocessor.
@saykit/svelte — runtime <Say>, getSay, SayProvider, Svelte renderer.
- An
examples/svelte (Vite + @sveltejs/vite-plugin-svelte + unplugin-saykit) end-to-end
example and integration docs on the website.
@saykit/sveltekit for SSR as a follow-up.
Summary
Add first-class Svelte (and, downstream, SvelteKit) support to SayKit, mirroring what we ship
for React via
@saykit/reactand@saykit/transform-jsx, and what's proposed for Vue in #40. Thismeans 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, agetSay()accessor built on Svelte context, a provider that seedsthat context, and a Svelte-flavoured renderer.
@saykit/transform-svelte— the compile-time transformer (the analogue of@saykit/transform-jsx): parses.sveltecomponents, extracts messages from the markup and<script>blocks, and rewrites the macros into runtime<Say>calls.@saykit/sveltekit— SSR +hooks/loadintegration, analogous to the Reactserver helpers and the proposed
@saykit/nuxt. Out of scope for the first pass but worthdesigning 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:
Transformer(packages/config/src/shapes.ts)is a pure source-to-source object:
{ match(id), extract(code, id), transform(code, id) }.extractreturns ICU messages for the CLI to write into catalogues;transformrewritesthe authored macros (
say`...`,<Say>...</Say>) into runtime calls that read fromthe compiled catalogue. This is used both by the CLI (
saykit extract) and by thebundler plugins.
@saykit/transform-jsximplements that interface with Babel:@babel/parserbuildsa JSX/TSX AST,
@babel/traversewalks it looking forsaytagged templates and<Say>JSX elements, and
@babel/generatorprints the rewritten program back out. Text +interpolations + nested elements get folded into our
CompositeMessagemodel(
ArgumentMessage,ElementMessage,ChoiceMessage).@saykit/reactis the runtime:<Say>is a macro that resolves toGET_SAY().call(descriptor),producing an ICU-rendered string with placeholder tags like
<0>...</0>, which aframework-agnostic
Rendererre-inflates into React elements (mapping tag indices back tothe elements/components passed in).
SayProvider+useSaydistribute theSayinstancevia React context.
unplugin-saykit(enforce: 'pre') callsbucket.transformer.transform(code, id)on eachmatched file, and injects the compiled catalogue at
loadtime.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
.sveltefile isn't a single JSgrammar — it's
<script>/<script module>blocks plus markup ({expr}interpolation,{#if}/{#each}/{@html}blocks, components) plus<style>.svelte/compiler— the official compiler exportsparse(), which returns a Svelte ASTcovering the script blocks and the markup, with source
start/endoffsets on every node.This is the entry point for
@saykit/transform-svelteand is the direct analogue of@babel/parser.<script>/<script module>— these are just JS/TS. We can very likely reuse@saykit/transform-jsalmost verbatim for thesay`...`tagged-template macro insidethe script block, exactly as proposed for Vue's
<script setup>. That part is close to free.{expression}interpolation nodes (single braces, unlike Vue's{{ }}), so we can find<Say>elements and interpolations and fold them onto ourCompositeMessagemodel just likethe JSX parser does.
The catch (identical to Vue): Svelte's compiler compiles down to JS; there's no
@babel/generatorequivalent that round-trips markup AST →.sveltesource. Sotransformneeds to be surgical string splicing over the original source using the node
start/endoffsets — a
MagicString-style approach. The good news is this is idiomatic in Svelte:Svelte's compiler and its preprocessors already use
MagicStringand 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:
svelte/compilerexposespreprocess(source, preprocessors),and
svelte.config.jshas apreprocessfield. 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-sveltecan beexposed both as a saykit
Transformer(for the CLI + unplugin) and as a drop-in Sveltepreprocessor for users who'd rather wire it into
svelte.config.js. This is cleaner than theVue situation, where we had to choose between raw-source transforms and compiler node-transforms.
enforce: 'pre', so@saykit/transform-svelte'stransformruns on the raw.sveltesourcebefore
@sveltejs/vite-plugin-sveltecompiles it. The CLIextract/transformpath uses thesame transformer standalone.
Either way,
unplugin-saykitjust needs@saykit/transform-svelteregistered as a buckettransformer 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:
App setup — you construct the core
Sayinstance yourself (the normalnew 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:This is deliberately bring-your-own-instance, matching our React server helper
(
setSay(say)inpackages/integration-react/src/runtime/server.ts), which already takes areal
Sayand clones+freezes it. The React clientSayProvideris the odd one out — ittakes plain
locale/messagesprops and news up the instance internally, but only becausethose props must cross the React Server Components serialization boundary. Svelte has no such
boundary, so there's no reason to hide the constructor.
The
Sayinstance is not reactive, and that's fine — it isn't in React either. Switchinglocale works the same way: swap the provided instance (re-
activateand re-provide) rather thanexpecting fine-grained reactivity from
Sayitself.Tagged template in
<script>(reuses@saykit/transform-jssemantics):<Say>in markup — the interesting bit. Text,{ }interpolations, and nestedelements/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.Plural>relies on Svelte's dotted/namespaced component syntax, which it supports.)getSay()is the Svelte analogue of the ReactuseSayhook — itgetContext()s theinstance 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 thatinto real nodes at runtime. React and Vue both have a runtime element factory for this
(
createElement/h()), sopackages/integration-react/src/components/renderer.tscan buildan 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 thecaller-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
<svelte:element>+ snippets can reconstructthe
<0>...</0>placeholder tree at runtime, and how expensive/ergonomic that is versusReact/Vue. This is the biggest unknown.
MagicString-over-offsets approach for the markupblock, since Svelte has no markup generator to round-trip through (same as Vue).
@saykit/transform-svelteas both a saykitTransformerand a Svelte preprocessor? I lean yes, since the preprocessor form is what Svelte users expect.
our
ElementMessagechildren (<a>docs</a>inside a<Say>).<Say>component vs. an action/attribute form: ause:sayaction might be natural fortext-only or attribute cases; the component covers rich/nested content.
<script module>reuse: confirm@saykit/transform-jscan be dropped in for both theinstance and module script blocks without runes-specific surprises.
@saykit/svelteso a later@saykit/sveltekit(locale inhooks.server,load-based hydration, root-layout provider) drops in without breakingchanges, mirroring
@saykit/react's server helpers.package: svelteandpackage: transform-svelteto match theexisting per-package label convention in
.github/labels.yml.Suggested rollout
<svelte:element>+ snippets) to de-risk the hardest part.@saykit/transform-svelte—.svelteparsing + extraction first (unlockssaykit extractfor
.svelte), thentransform; expose as both aTransformerand a Svelte preprocessor.@saykit/svelte— runtime<Say>,getSay,SayProvider, Svelte renderer.examples/svelte(Vite +@sveltejs/vite-plugin-svelte+unplugin-saykit) end-to-endexample and integration docs on the website.
@saykit/sveltekitfor SSR as a follow-up.