# LiveSvelteGettext
**Status:** Proof of Concept
A compile-time solution for using Phoenix Gettext translations in Svelte components.
## The Problem
When using [live_svelte](https://github.com/woutdp/live_svelte) with Phoenix, there's no straightforward way to use gettext translations in Svelte components. This issue was raised in [live_svelte#120](https://github.com/woutdp/live_svelte/issues/120).
The challenges:
- Svelte components need access to translations at runtime
- `mix gettext.extract` needs to discover translation strings in `.svelte` files
- `.po` file references should point to the actual Svelte source file:line for maintainability
- Ideally, no generated files to commit or manually maintain
## The Solution
This library uses Elixir macros at compile time to:
1. Scan `.svelte` files for `gettext()` and `ngettext()` calls
2. Generate Elixir code that `mix gettext.extract` can discover
3. Inject accurate source references into `.pot` files via custom extractor
4. Provide runtime translation functions for Svelte via a TypeScript library
No generated files are committed - everything happens at compile time using `@external_resource` for automatic recompilation.
## Key Features
- **Compile-Time Extraction**: Scans Svelte files during compilation
- **Phoenix Gettext Compatible**: Works with existing `mix gettext.extract` workflow
- **Accurate Source References**: `.pot` files show `assets/svelte/Button.svelte:42` instead of generated code locations
- **Type-Safe Client**: TypeScript library for runtime translations
- **Simple Setup**: Igniter installer handles configuration
- **Automatic Initialization**: Translations automatically load on first use (no manual setup required)
## Installation
### Automatic Installation (Recommended)
1. **Add the dependency** to your `mix.exs`:
```elixir
# mix.exs
def deps do
[
{:live_svelte_gettext, "~> 0.1.0"}
]
end
```
2. **Run the Igniter installer**:
```bash
mix deps.get
mix igniter.install live_svelte_gettext
```
The installer will:
- Detect your Gettext backend automatically
- Find your Svelte directory
- Create a separate `SvelteStrings` module with the correct configuration
- Add `import LiveSvelteGettext.Components` to your web module
- Configure `config/config.exs`
3. **(Optional) Install the npm package**:
You can either install via npm or use the bundled files from the Hex package:
```bash
# Option A: Install from npm (recommended for version management)
npm install live-svelte-gettext
# Option B: Use bundled files (no installation needed)
# The library is available at deps/live_svelte_gettext/assets/dist/
# Your bundler should resolve it automatically
```
That's it! You're ready to use translations in your Svelte components - no JavaScript setup required!
### Manual Installation
If the automatic installer doesn't work for your project:
1. **Add the dependency** to your `mix.exs`:
```elixir
def deps do
[
{:live_svelte_gettext, "~> 0.1.0"}
]
end
```
2. **Create a separate SvelteStrings module** (required to avoid circular dependency):
**Important:** Do NOT add `use LiveSvelteGettext` to your main Gettext backend module.
This creates a circular dependency that causes compilation errors. Always use a separate module.
```elixir
# lib/my_app_web/gettext/svelte_strings.ex
defmodule MyAppWeb.Gettext.SvelteStrings do
@moduledoc """
Translation strings extracted from Svelte components.
This module is automatically managed by LiveSvelteGettext.
"""
use Gettext.Backend, otp_app: :my_app
use LiveSvelteGettext,
gettext_backend: MyAppWeb.Gettext,
svelte_path: "assets/svelte"
end
```
4. **Configure the Gettext module** in `config/config.exs`:
```elixir
# config/config.exs
config :live_svelte_gettext,
gettext: MyAppWeb.Gettext
```
5. **Add the import to your web module** (`lib/my_app_web.ex`):
```elixir
def html do
quote do
# ... existing imports ...
import LiveSvelteGettext.Components
end
end
def live_view do
quote do
# ... existing imports ...
import LiveSvelteGettext.Components
end
end
```
6. **(Optional) Install the npm package**:
```bash
# Option A: Install from npm
npm install live-svelte-gettext
# Option B: Use bundled files (no installation needed)
# Available at deps/live_svelte_gettext/assets/dist/
```
**That's it!** Translations automatically initialize on first use.
## Quick Start
Once installed, you can start using translations in your Svelte components immediately.
### 1. Inject translations into your template
Add the `<.svelte_translations />` component in your layout or LiveView template. This component renders a `<script>` tag containing translations as JSON:
```heex
<!-- In your layout or LiveView template -->
<.svelte_translations />
<.svelte name="MyComponent" props={%{...}} />
```
The component renders a `<script>` tag with translations as JSON. Translations are automatically initialized on first use (lazy initialization).
**How it works:**
- Component fetches translations for the current locale from your Gettext backend
- Renders them as JSON in a `<script id="svelte-translations">` tag
- Translations are automatically initialized when you first call `gettext()` or `ngettext()`
- Your Svelte components can now call `gettext()` and `ngettext()`
**Advanced usage:**
```heex
<!-- Override locale -->
<.svelte_translations locale="es" />
<!-- Explicit Gettext module (for multi-tenant apps) -->
<.svelte_translations gettext_module={@tenant.gettext_module} />
<!-- Custom script tag ID -->
<.svelte_translations id="custom-translations" />
```
### 2. Use translations in your Svelte components
```svelte
<script>
import { gettext, ngettext } from 'live-svelte-gettext'
let itemCount = 5
</script>
<div>
<h1>{gettext("Welcome to our app")}</h1>
<p>{gettext("Hello, %{name}", { name: "World" })}</p>
<p>{ngettext("1 item", "%{count} items", itemCount)}</p>
</div>
```
That's it! No manual initialization needed - translations are automatically initialized on first use.
### 3. Extract and translate
```bash
# Extract translation strings from both Elixir and Svelte files
mix gettext.extract
# Merge into locale files
mix gettext.merge priv/gettext
# Edit your .po files to add translations
# Then your Svelte components will automatically use the translated strings!
```
## How It Works
This POC uses a compile-time macro approach to bridge Elixir's gettext and Svelte's runtime:
### Compile Time
1. **File Scanning**: When you compile, the `use LiveSvelteGettext` macro runs and scans all `.svelte` files
2. **String Extraction**: Regex patterns extract `gettext()` and `ngettext()` calls with their file:line locations
3. **Code Generation**: The macro generates Elixir code in your module with:
- `@external_resource` attributes (triggers recompilation when Svelte files change)
- Calls to `CustomExtractor.extract_with_location/8` (preserves accurate source references)
- An `all_translations/1` function for runtime access
4. **Gettext Discovery**: When you run `mix gettext.extract`, it discovers the generated extraction calls
5. **Accurate References**: The `CustomExtractor` modifies `Macro.Env` to inject the actual Svelte file:line into `.pot` files
### Runtime
1. **Server Side**: The `<.svelte_translations />` component fetches translations and renders them as JSON in a `<script>` tag
2. **Client Side**: Translations are automatically loaded from the script tag on first use (lazy initialization)
3. **Svelte Components**: Call `gettext()` and `ngettext()` - interpolation and pluralization happen in the browser
### No Generated Files
Everything is generated at compile time in memory. No intermediate files to commit or maintain.
## Architectural Decisions
These are the key design choices made in this POC and the reasoning behind them:
### 1. Script Tag for Translation Injection (Not Props)
**Decision**: Pass translations via a `<script>` tag with JSON rather than as props to each Svelte component.
**Reasoning**:
- **Performance**: Avoids serializing potentially large translation objects multiple times per page
- **Global Access**: All Svelte components can access translations without prop drilling
- **Separation of Concerns**: Translation data is separate from component props
- **Caching**: The browser can cache the inline script across LiveView updates
This is a preference based on architectural feel rather than hard performance data.
### 2. Compile-Time Macro Generation
**Decision**: Use Elixir macros to generate code at compile time rather than runtime discovery or generated files.
**Reasoning**:
- **No Committed Files**: Avoids generated `.ex` or `.json` files in version control
- **Phoenix Integration**: Generated code naturally integrates with `mix gettext.extract`
- **Automatic Updates**: `@external_resource` triggers recompilation when Svelte files change
- **No Runtime Cost**: All extraction work happens once at compile time
This keeps the developer workflow simple: write `gettext()` in Svelte, run `mix compile` and `mix gettext.extract`.
### 3. Full .po File Compatibility
**Decision**: Ensure complete compatibility with Phoenix's gettext toolchain, including accurate source references.
**Reasoning**:
- **Existing Tools**: Developers can use their existing translation workflows
- **Reference Accuracy**: `.pot` files showing `assets/svelte/Button.svelte:42` helps translators understand context
- **CLI Tool Integration**: Makes it possible to use tools like [poflow](https://github.com/xNilsson/poflow) for AI-assisted translation. `poflow` is a tool built by me to make .po files changes more efficiently with llms.
- **No Learning Curve**: Developers already know `mix gettext.extract` and `.po` file workflows
The `CustomExtractor` was necessary to solve the "all references point to the macro invocation line" problem.
### 4. NPM Package for TypeScript Client
**Decision**: Create a standalone npm package (`live-svelte-gettext`) for the runtime translation functions.
**Reasoning**:
- **Minimal Setup**: Developers can `import { gettext } from 'live-svelte-gettext'` immediately
- **Type Safety**: Full TypeScript types for better DX
- **Reusability**: The runtime library could work with other backends in the future
- **Familiar Pattern**: Follows standard npm package conventions
The package will be published to npm for easy installation.
## Architecture
### Compile Time (Elixir)
When you run `mix compile`:
1. **Scan Svelte files** - `LiveSvelteGettext.Extractor` scans all `.svelte` files in your configured path
2. **Extract strings** - Regex patterns find `gettext()` and `ngettext()` calls with file:line metadata
3. **Generate code** - `LiveSvelteGettext.Compiler` generates:
- `@external_resource` attributes (triggers recompilation when files change)
- Calls to `CustomExtractor.extract_with_location/8` (preserves source locations)
- An `all_translations/1` function for runtime use
- A `__lsg_metadata__/0` debug function
↓
### Translation Extraction
When you run `mix gettext.extract`:
4. **Discover strings** - Gettext finds the generated extraction calls
5. **Inject references** - `CustomExtractor` modifies `Macro.Env` to inject actual Svelte file:line
6. **Write POT files** - Creates/updates `priv/gettext/default.pot` with accurate references:
```
#: assets/svelte/components/Button.svelte:42
msgid "Save Profile"
```
↓
### Runtime (Server)
When a page loads:
7. **Fetch translations** - The `<.svelte_translations />` component calls `YourModule.all_translations(locale)`
8. **Render JSON** - Translations are rendered in a `<script id="svelte-translations">` tag
↓
### Runtime (Client/Browser)
9. **Lazy initialization** - On first `gettext()` or `ngettext()` call, translations are automatically loaded from the script tag
10. **Use translations** - Svelte components call `gettext()` and `ngettext()`
11. **Interpolate** - The TypeScript library handles variable substitution and pluralization
No Phoenix hooks required - everything initializes automatically!
## API Documentation
Full API documentation is available on [HexDocs](https://hexdocs.pm/live_svelte_gettext).
### Key Modules
- **`LiveSvelteGettext`** - Main module to `use` in your Gettext backend
- **`LiveSvelteGettext.Components`** - Phoenix components for injecting translations
- **`LiveSvelteGettext.Extractor`** - Extracts translation strings from Svelte files
- **`LiveSvelteGettext.Compiler`** - Generates code at compile time
### TypeScript API
```typescript
// Get translated string
gettext(key: string, vars?: Record<string, string | number>): string
// Get translated string with pluralization
ngettext(singular: string, plural: string, count: number, vars?: Record<string, string | number>): string
// Initialize translations manually (optional - automatically happens on first use)
initTranslations(translations: Record<string, string>): void
// Check if initialized
isInitialized(): boolean
// Reset (useful for testing)
resetTranslations(): void
```
## Troubleshooting
### Translations not updating after changing Svelte files
Make sure your Svelte files are being watched for changes. Run:
```bash
mix clean
mix compile
```
The module should recompile automatically when Svelte files change due to `@external_resource`.
### Import errors
If you get import errors for `live-svelte-gettext`, you have two options:
```bash
# Option 1: Install via npm
npm install live-svelte-gettext
# Option 2: Use bundled files from Hex package
# Ensure the dependency is fetched
mix deps.get
# The library is available at deps/live_svelte_gettext/assets/
# Your bundler should resolve it automatically based on package.json
```
Translations will automatically initialize on first use - no setup required!
### Gettext.extract not finding Svelte strings
Make sure your `SvelteStrings` module is compiling successfully. Check for compilation errors:
```bash
mix compile
```
If there are no errors, verify that strings are being extracted:
```elixir
# In IEx
iex> MyAppWeb.SvelteStrings.__lsg_metadata__()
%{
extractions: [...], # Should list your strings
svelte_files: [...], # Should list your .svelte files
gettext_backend: MyAppWeb.Gettext
}
```
### Translations showing keys instead of translated text
This usually means:
1. You haven't run `mix gettext.extract` and `mix gettext.merge` yet
2. The translations haven't been added to your `.po` files
3. The locale isn't set correctly
Check your locale:
```elixir
Gettext.get_locale(MyAppWeb.Gettext)
```
### Escaped quotes not working in Svelte
Use the appropriate escape sequence:
```svelte
{gettext("She said, \"Hello\"")} <!-- Double quotes inside double quotes -->
{gettext('He\'s here')} <!-- Single quote inside single quotes -->
```
### Module not recompiling when expected
Force a recompilation:
```bash
mix clean
mix deps.clean live_svelte_gettext
mix deps.get
mix compile
```
### POT files showing incorrect Svelte file references
As of v0.1.0, LiveSvelteGettext automatically injects correct Svelte file:line references during `mix gettext.extract` via `CustomExtractor`. You should see references like:
```
#: assets/svelte/components/Button.svelte:42
msgid "Save Profile"
```
If you see incorrect references (like `lib/my_app_web/svelte_strings.ex:39` for all strings), this usually means:
1. **Migration from older version**: Run `mix live_svelte_gettext.fix_references` to update existing POT files
2. **CustomExtractor not working**: This is likely a bug - please report it!
The `fix_references` task is primarily a fallback tool and shouldn't be needed for normal operation.
## Contributing
Contributions are welcome! Here's how you can help:
1. **Report bugs**: Open an issue with a minimal reproduction case
2. **Suggest features**: Open an issue describing the use case and proposed API
3. **Submit pull requests**:
- Fork the repository
- Create a feature branch
- Add tests for new functionality
- Ensure all tests pass with `mix test`
- Run `mix format` before committing
- Open a PR with a clear description
### Development Setup
```bash
# Clone the repository
git clone https://github.com/xnilsson/live_svelte_gettext.git
cd live_svelte_gettext
# Install dependencies
mix deps.get
# Run tests
mix test
# Run tests with coverage
mix coveralls.html
# Format code
mix format
# Type checking
mix dialyzer
```
### Running Tests
```bash
# Run all tests
mix test
# Run specific test file
mix test test/live_svelte_gettext/extractor_test.exs
# Run with coverage
mix coveralls.html
open cover/excoveralls.html
```
## Project Status & Future
### Current Status
This is a **proof of concept** extracted from a real project where it solves a practical need. It works well for the use case it was designed for, but has not been widely tested across different Phoenix/Svelte setups.
**What's working:**
- Compile-time extraction from Svelte files
- Integration with `mix gettext.extract`
- Accurate source references in `.pot` files
- Runtime translations with interpolation and pluralization
- Automatic lazy initialization (no manual setup required)
- Igniter-based installation
**Known limitations:**
- Simple English plural rules only (no CLDR plural forms for other languages)
- Regex-based extraction (won't handle all edge cases like template literals or computed strings)
- Not tested with domains (`dgettext`) or contexts (`pgettext`)
### Sharing with live_svelte Community
This POC was created in response to [live_svelte#120](https://github.com/woutdp/live_svelte/issues/120). The goal is to:
1. **Share the approach** - Show that compile-time macro extraction can work
2. **Get feedback** - Learn if this solves the problem for others
3. **Discuss integration** - Potentially merge concepts into live_svelte or keep as separate library
If you're interested in using this or have ideas for improvement, please open an issue or discussion!
### Possible Future Directions
**If this POC proves useful:**
- CLDR plural rules for accurate pluralization across languages
- Domain and context support (dgettext, pgettext)
- More robust parsing (proper Svelte AST instead of regex)
- Support for other frontend frameworks (React, Vue, etc.)
**Alternative approaches to consider:**
- Babel/SWC plugin for extraction (more accurate than regex)
- Build-time JSON generation (simpler but requires committing files)
- Integration directly into live_svelte (would benefit all users)
## For Library Authors
If you're building a compile-time i18n extractor for a non-Elixir templating system (like Svelte, Surface, Temple, etc.), you may encounter the same challenge we faced: all extracted translation strings reference the macro invocation line instead of the original source file locations.
**The Problem:**
```elixir
# lib/my_app_web/template_strings.ex:39
use MyI18nExtractor # <-- All strings reference this line
# In POT file:
#: lib/my_app_web/template_strings.ex:39
msgid "Save Profile"
#: lib/my_app_web/template_strings.ex:39
msgid "Delete Account"
```
**Our Solution:**
We solved this by creating a custom extractor that modifies `Macro.Env` before calling `Gettext.Extractor.extract/6`. See `lib/live_svelte_gettext/custom_extractor.ex` for the implementation.
The key insight:
```elixir
def extract_with_location(env, backend, domain, msgctxt, msgid, extracted_comments, file, line) do
# Create a modified environment with custom file and line
modified_env = %{env | file: file, line: line}
# Gettext reads env.file and env.line
Gettext.Extractor.extract(
modified_env,
backend,
domain,
msgctxt,
msgid,
extracted_comments
)
end
```
This produces accurate references in POT files:
```
#: assets/svelte/components/Button.svelte:42
msgid "Save Profile"
#: assets/templates/settings.sface:18
msgid "Delete Account"
```
Feel free to copy this pattern for your own compile-time extraction needs!
## License
MIT License - see [LICENSE](LICENSE) file for details.
Copyright (c) 2025 Christopher Nilsson