intlayer_swc_plugin/lib.rs
1//! # intlayer-swc-plugin
2//!
3//! An SWC transform plugin for [Intlayer](https://intlayer.org) that replaces
4//! `useIntlayer` / `getIntlayer` / `useTranslations` call arguments with
5//! pre-loaded dictionary imports at compile time, and rewrites content field
6//! accesses to the short aliases assigned by the minification pipeline.
7//!
8//! ## What it does
9//!
10//! Given source code like:
11//!
12//! ```js
13//! import { useIntlayer } from "react-intlayer";
14//! const t = useIntlayer("locale-switcher");
15//! ```
16//!
17//! The plugin rewrites it to:
18//!
19//! ```js
20//! import _abc123 from "../../.intlayer/dictionaries/locale-switcher.json" with { type: "json" };
21//! import { useDictionary as useIntlayer } from "react-intlayer";
22//! const t = useIntlayer(_abc123);
23//! ```
24//!
25//! This eliminates the runtime registry lookup and enables tree-shaking for
26//! per-locale bundles.
27//!
28//! When the `build.minify` pipeline has renamed the compiled dictionary's
29//! fields, the matching source accesses are rewritten too
30//! (`content.title` → `content.a`), driven by the `fieldRenameMap` option.
31//!
32//! ## Pipeline
33//!
34//! Each file goes through, in order:
35//!
36//! 1. [`field_rename`] – rewrite content field accesses to their short alias.
37//! Runs first because it keys off the dictionary key that step 3 erases.
38//! 2. [`pre_pass`] – discover the local names of every recognised caller, the
39//! package each was imported from, and which of those packages resolve a
40//! dictionary to a dynamic/fetch loader.
41//! 3. [`optimize`] – rewrite the call sites and import specifiers.
42//! 4. [`imports`] – inject the dictionary imports the rewrite created.
43//!
44//! ## Compat adapters
45//!
46//! Steps 2 and 3 recognise the base intlayer getters only. The compat adapters
47//! (`@intlayer/react-i18next`, `@intlayer/next-intl`, …) reach the plugin as
48//! `extraCallers` descriptors injected by their own bundler plugin, and every
49//! adapter-specific decision lives behind them:
50//!
51//! - [`extra_caller`] – matching a compat caller, resolving its namespace, and
52//! rewriting its call sites and import specifiers.
53//!
54//! With no `extraCallers` configured, [`optimize`] holds no
55//! [`extra_caller::ExtraCallerContext`] at all, so none of that code runs and
56//! the base rewrite behaves exactly as if the adapters did not exist.
57//!
58//! ## Division of labour with the JavaScript side
59//!
60//! Purging unused fields and assigning short aliases require reading every
61//! component source file and rewriting the compiled dictionary JSON — file I/O
62//! and cross-file state a per-file Wasm transform cannot do. That analysis runs
63//! on the JavaScript side (`@intlayer/babel`, invoked from `withIntlayer`), and
64//! its result reaches this crate as the `fieldRenameMap` option.
65//!
66//! That option comes back empty when the visual editor is enabled — the editor
67//! resolves its edits by `keyPath`, which renaming would invalidate — so this
68//! crate then only rewrites call sites and imports. Purging is unaffected and
69//! happens on the JavaScript side either way.
70//!
71//! ## Usage as an SWC / Next.js Wasm plugin
72//!
73//! The crate is distributed on npm as
74//! [`@intlayer/swc`](https://www.npmjs.com/package/@intlayer/swc).
75//! Configure it in your `next.config.*`:
76//!
77//! ```js
78//! const nextConfig = {
79//! experimental: {
80//! swcPlugins: [["@intlayer/swc", { /* PluginConfig fields */ }]],
81//! },
82//! };
83//! ```
84//!
85//! ## Usage as a native Rust library
86//!
87//! Add to `Cargo.toml`:
88//!
89//! ```toml
90//! [dependencies]
91//! intlayer-swc-plugin = "7"
92//! ```
93//!
94//! Then call [`process_transform`] directly:
95//!
96//! ```rust,no_run
97//! use intlayer_swc_plugin::{PluginConfig, process_transform};
98//! use swc_core::ecma::ast::Program;
99//!
100//! fn my_transform(program: Program) -> Program {
101//! let config = PluginConfig {
102//! dictionaries_dir: "/project/.intlayer/dictionaries".into(),
103//! dictionaries_entry_path: "/project/.intlayer/dictionaries.mjs".into(),
104//! dynamic_dictionaries_dir: "/project/.intlayer/dynamic_dictionaries".into(),
105//! fetch_dictionaries_dir: "/project/.intlayer/fetch_dictionaries".into(),
106//! import_mode: Some("static".into()),
107//! replace_dictionary_entry: Some(false),
108//! ..PluginConfig::default()
109//! };
110//! process_transform(program, config, "/project/src/page.tsx".into())
111//! }
112//! ```
113
114pub mod ast;
115pub mod config;
116pub mod dictionary_entry;
117pub mod dictionary_imports;
118pub mod extra_caller;
119pub mod field_rename;
120pub mod imports;
121pub mod logger;
122pub mod optimize;
123pub mod packages;
124pub mod paths;
125pub mod pre_pass;
126
127#[cfg(test)]
128mod tests;
129
130pub use config::{
131 ExtraCallerConfig, FieldRenameMap, FieldRenameNode, LogLevel, NamespaceOptionConfig,
132 PluginConfig,
133};
134pub use paths::normalize_path;
135
136use crate::{
137 dictionary_entry::build_empty_dictionaries_entry,
138 dictionary_imports::ImportKind,
139 extra_caller::ExtraCallerContext,
140 imports::{inject_dictionary_imports, DictionaryDirs},
141 logger::{Logger, TransformSummary},
142 optimize::TransformVisitor,
143 pre_pass::run_pre_pass,
144};
145use std::collections::HashSet;
146use swc_core::ecma::{ast::Program, visit::VisitMutWith};
147
148#[cfg(feature = "plugin")]
149use swc_core::plugin::{
150 metadata::{TransformPluginMetadataContextKind, TransformPluginProgramMetadata},
151 plugin_transform,
152};
153
154/// Resolves the file this transform should work on, as a normalised path.
155///
156/// When `files_list` is empty every file is processed and the build-tool
157/// filename is used as-is. Otherwise the matching allowlist entry wins, because
158/// it carries the absolute path the relative import specifiers are computed
159/// from; a file absent from the allowlist is skipped.
160///
161/// Both sides are normalised before being compared: the allowlist is built by
162/// the JavaScript host, which may hand over Windows-style separators the
163/// bundler's filename does not use.
164fn resolve_working_filename(
165 files_list: &[String],
166 filename_raw: &str,
167 logger: &Logger,
168) -> Option<String> {
169 let normalized_filename = normalize_path(filename_raw);
170
171 if files_list.is_empty() {
172 logger.debug(format!(
173 "processing {} (no filesList allowlist configured)",
174 filename_raw
175 ));
176 return Some(normalized_filename.into_owned());
177 }
178
179 let Some(matched) = files_list.iter().find_map(|target| {
180 let normalized_target = normalize_path(target);
181
182 let is_match = normalized_filename.ends_with(normalized_target.as_ref())
183 || normalized_target.ends_with(normalized_filename.as_ref());
184
185 is_match.then_some(normalized_target)
186 }) else {
187 logger.debug(format!("skipping {} (not in filesList)", filename_raw));
188 return None;
189 };
190
191 logger.debug(format!(
192 "processing {} (matched allowlist entry {})",
193 filename_raw, matched
194 ));
195 Some(matched.into_owned())
196}
197
198/// Applies the Intlayer SWC transform to `program`.
199///
200/// This is the core transformation function exposed as a native Rust API.
201/// The Wasm plugin entry point ([`transform`]) delegates directly to this
202/// function after deserialising the JSON plugin config.
203///
204/// # Arguments
205///
206/// * `program` – The parsed SWC AST to transform.
207/// * `cfg` – Plugin configuration (see [`PluginConfig`]).
208/// * `filename_raw` – Absolute path of the file being compiled, as provided
209/// by the build tool. Used to compute relative import paths for injected
210/// dictionary imports.
211///
212/// # Returns
213///
214/// The transformed AST.
215pub fn process_transform(
216 mut program: Program,
217 mut cfg: PluginConfig,
218 filename_raw: String,
219) -> Program {
220 let logger = Logger::new(if logger::DEBUG_LOG {
221 LogLevel::Debug
222 } else {
223 LogLevel::from_option(cfg.log_level.as_deref())
224 });
225
226 cfg.dictionaries_dir = normalize_path(&cfg.dictionaries_dir).into_owned();
227 cfg.dynamic_dictionaries_dir = normalize_path(&cfg.dynamic_dictionaries_dir).into_owned();
228 cfg.fetch_dictionaries_dir = normalize_path(&cfg.fetch_dictionaries_dir).into_owned();
229 cfg.dictionaries_entry_path = normalize_path(&cfg.dictionaries_entry_path).into_owned();
230
231 let Some(working_filename) = resolve_working_filename(&cfg.files_list, &filename_raw, &logger)
232 else {
233 return program;
234 };
235
236 // The generated dictionaries entry is emptied wholesale: every call site
237 // now reads from a direct import, so keeping the registry alive would pin
238 // every dictionary into the bundle.
239 if cfg.replace_dictionary_entry.unwrap_or(false) {
240 let is_main_entry = working_filename == cfg.dictionaries_entry_path
241 || normalize_path(&filename_raw) == cfg.dictionaries_entry_path.as_str();
242
243 if is_main_entry {
244 logger.info(format!("{}: emptied dictionaries entry", filename_raw));
245 return build_empty_dictionaries_entry();
246 }
247 }
248
249 // Step 1 — content field renames (minify). Must run before the optimize
250 // transform replaces `useIntlayer` with `useDictionary`.
251 let mut summary = TransformSummary {
252 renamed_fields: field_rename::rename_field_accesses(&mut program, &cfg.field_rename_map),
253 ..TransformSummary::default()
254 };
255
256 let import_mode =
257 ImportKind::from_option(cfg.import_mode.as_deref()).unwrap_or(ImportKind::Static);
258 let dictionary_mode_map = cfg.dictionary_mode_map.take().unwrap_or_default();
259
260 // Step 2 — discover callers and the file-level dynamic decision.
261 let pre_pass = run_pre_pass(&program, &dictionary_mode_map, &cfg.extra_callers);
262
263 // Compat adapters plug in here and nowhere else: with no `extraCallers`
264 // configured the context stays `None` and the optimize transform runs the
265 // base intlayer rewrite untouched.
266 let extra_caller_context = (!cfg.extra_callers.is_empty()).then(|| ExtraCallerContext {
267 extra_callers: &cfg.extra_callers,
268 dictionary_mode_map: &dictionary_mode_map,
269 import_mode,
270 use_dynamic_helpers: import_mode != ImportKind::Static || pre_pass.extra_has_dynamic_call,
271 });
272
273 // Step 3 — rewrite the call sites and import specifiers.
274 let mut visitor = TransformVisitor::new(
275 import_mode,
276 &dictionary_mode_map,
277 &pre_pass.caller_map,
278 &pre_pass.packages_with_dynamic_call,
279 &pre_pass.packages_with_fetch_call,
280 extra_caller_context,
281 );
282 program.visit_mut_with(&mut visitor);
283
284 summary.static_imports = visitor.injected_imports.static_imports.len();
285 summary.dynamic_imports = visitor.injected_imports.dynamic_imports.len();
286
287 // Step 4 — inject the dictionary imports the rewrite created.
288 let nesting_dictionary_keys: HashSet<&str> = cfg
289 .nesting_dictionary_keys
290 .iter()
291 .map(String::as_str)
292 .collect();
293
294 inject_dictionary_imports(
295 &mut program,
296 &visitor.injected_imports,
297 &DictionaryDirs {
298 dictionaries_dir: &cfg.dictionaries_dir,
299 dynamic_dictionaries_dir: &cfg.dynamic_dictionaries_dir,
300 fetch_dictionaries_dir: &cfg.fetch_dictionaries_dir,
301 },
302 &nesting_dictionary_keys,
303 &working_filename,
304 );
305
306 logger.report_file(&filename_raw, &summary, &program);
307
308 program
309}
310
311// ─────────────────────────────────────────────────────────────────────────────
312// WASM PLUGIN ENTRY POINT
313// ─────────────────────────────────────────────────────────────────────────────
314
315/// SWC Wasm plugin entry point.
316///
317/// This function is only compiled when the `plugin` feature is enabled
318/// (i.e. when building for `wasm32-wasip1` / `wasm32-unknown-unknown`).
319/// Native Rust consumers should call [`process_transform`] directly instead.
320#[cfg(feature = "plugin")]
321#[plugin_transform]
322pub fn transform(program: Program, metadata: TransformPluginProgramMetadata) -> Program {
323 let cfg: PluginConfig = match metadata
324 .get_transform_plugin_config()
325 .and_then(|raw| serde_json::from_str::<PluginConfig>(&raw).ok())
326 {
327 Some(config) => config,
328 None => return program,
329 };
330
331 let filename_raw = match metadata.get_context(&TransformPluginMetadataContextKind::Filename) {
332 Some(filename) => filename,
333 None => return program,
334 };
335
336 process_transform(program, cfg, filename_raw)
337}