Skip to main content

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}