1use serde::{Deserialize, Serialize};
2use std::io;
3use swc_core::common::{
4 DUMMY_SP, FileName, Globals, Mark, SourceMap, Spanned, SyntaxContext,
5 comments::SingleThreadedComments, errors::Handler, sync::Lrc,
6};
7use swc_core::ecma::ast::{self, EsVersion};
8use swc_core::ecma::codegen::{Config as CodegenConfig, Emitter, text_writer::JsWriter};
9use swc_core::ecma::parser::{EsSyntax, Parser, StringInput, Syntax, TsSyntax, lexer::Lexer};
10use swc_core::ecma::transforms::{base::resolver, react, typescript::strip as ts_strip};
11use swc_core::ecma::visit::{VisitMut, VisitMutWith};
12use swc_ecma_transforms_module::{
13 common_js::{self, FeatureFlag},
14 path::Resolver,
15 util::ImportInterop,
16};
17
18#[derive(Debug, thiserror::Error)]
22pub enum TranspileError {
23 #[error("Parse error in {filename} at {line}:{col} — {message}")]
24 ParseError {
25 filename: String,
26 line: usize,
27 col: usize,
28 message: String,
29 },
30 #[error("Transform error in {filename}: {source}")]
31 TransformError {
32 filename: String,
33 #[source]
34 source: anyhow::Error,
35 },
36 #[error("Codegen error in {filename}: {source}")]
37 CodegenError {
38 filename: String,
39 #[source]
40 source: anyhow::Error,
41 },
42}
43
44#[derive(Debug, Clone, Serialize, Deserialize, Default)]
45pub struct TranspileOptions {
46 pub filename: Option<String>,
47 pub react_dev: bool,
48 pub to_commonjs: bool,
49 pub pragma: Option<String>,
50 pub pragma_frag: Option<String>,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize)]
54pub struct TranspileOutput {
55 pub code: String,
56 pub map: Option<String>,
57}
58
59struct ImportRewriter;
61impl VisitMut for ImportRewriter {
62 fn visit_mut_expr(&mut self, n: &mut ast::Expr) {
63 n.visit_mut_children_with(self);
64 if let ast::Expr::Call(call) = n {
65 if let ast::Callee::Import(_) = call.callee {
66 let arg = call
68 .args
69 .get(0)
70 .map(|a| (*a.expr).clone())
71 .unwrap_or(ast::Expr::Lit(ast::Lit::Str(ast::Str {
72 span: DUMMY_SP,
73 value: "".into(),
74 raw: None,
75 })));
76 let ident_ctx =
77 |name: &str| ast::Ident::new(name.into(), DUMMY_SP, SyntaxContext::empty());
78 call.callee = ast::Callee::Expr(Box::new(ast::Expr::Ident(ident_ctx("__hook_import"))));
79 call.args = vec![ast::ExprOrSpread {
80 spread: None,
81 expr: Box::new(arg),
82 }];
83 call.type_args = None;
84 }
85 }
86 }
87}
88
89struct StaticImportRewriter;
97impl VisitMut for StaticImportRewriter {
98 fn visit_mut_module_items(&mut self, items: &mut Vec<ast::ModuleItem>) {
99 let mut new_items = Vec::with_capacity(items.len());
100 for item in items.drain(..) {
101 match item {
102 ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)) => {
103 let src = &import_decl.src.value;
105 let is_react = src == "react";
106 let is_jsx_runtime = src == "react/jsx-runtime" || src == "react/jsx-dev-runtime";
107 let is_file_renderer = src == "@relay/file-renderer";
108 let is_helpers = src == "@relay/helpers";
109
110 let global_name = if is_react {
111 Some("__hook_react")
112 } else if is_jsx_runtime {
113 Some("__hook_jsx_runtime")
114 } else if is_file_renderer {
115 Some("__hook_file_renderer")
116 } else if is_helpers {
117 Some("__hook_helpers")
118 } else {
119 None
120 };
121
122 if let Some(global) = global_name {
123 let ident_ctx = |name: &str| ast::Ident::new(
124 name.into(),
125 DUMMY_SP,
126 SyntaxContext::empty()
127 );
128
129 let global_member = ast::Expr::Member(ast::MemberExpr {
131 span: DUMMY_SP,
132 obj: Box::new(ast::Expr::Ident(ident_ctx("globalThis"))),
133 prop: ast::MemberProp::Ident(ast::IdentName::new(global.into(), DUMMY_SP)),
134 });
135
136 for spec in &import_decl.specifiers {
138 match spec {
139 ast::ImportSpecifier::Default(default_spec) => {
141 let local_name = default_spec.local.sym.clone();
142 let var_decl = ast::VarDecl {
143 span: DUMMY_SP,
144 kind: ast::VarDeclKind::Const,
145 declare: false,
146 decls: vec![ast::VarDeclarator {
147 span: DUMMY_SP,
148 name: ast::Pat::Ident(ast::BindingIdent {
149 id: ident_ctx(&local_name),
150 type_ann: None,
151 }),
152 init: Some(Box::new(global_member.clone())),
153 definite: false,
154 }],
155 ..Default::default()
156 };
157 new_items.push(ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(
158 Box::new(var_decl)
159 ))));
160 },
161 ast::ImportSpecifier::Named(named_spec) => {
163 let imported_name = match &named_spec.imported {
165 Some(ast::ModuleExportName::Ident(id)) => id.sym.clone(),
166 None => named_spec.local.sym.clone(),
167 _ => continue,
168 };
169 let local_name = named_spec.local.sym.clone();
170
171 let member_access = ast::Expr::Member(ast::MemberExpr {
174 span: DUMMY_SP,
175 obj: Box::new(global_member.clone()),
176 prop: ast::MemberProp::Ident(ast::IdentName::new(imported_name, DUMMY_SP)),
177 });
178
179 let var_decl = ast::VarDecl {
180 span: DUMMY_SP,
181 kind: ast::VarDeclKind::Const,
182 declare: false,
183 decls: vec![ast::VarDeclarator {
184 span: DUMMY_SP,
185 name: ast::Pat::Ident(ast::BindingIdent {
186 id: ident_ctx(&local_name),
187 type_ann: None,
188 }),
189 init: Some(Box::new(member_access)),
190 definite: false,
191 }],
192 ..Default::default()
193 };
194 new_items.push(ast::ModuleItem::Stmt(ast::Stmt::Decl(ast::Decl::Var(
195 Box::new(var_decl)
196 ))));
197 },
198 _ => {
199 }
201 }
202 }
203 } else {
204 new_items.push(ast::ModuleItem::ModuleDecl(ast::ModuleDecl::Import(import_decl)));
206 }
207 }
208 other => new_items.push(other),
209 }
210 }
211 *items = new_items;
212 }
213}
214
215fn run_module_pass(pass: impl ast::Pass, module: ast::Module) -> ast::Module {
216 let mut pass = pass;
217 let mut program = ast::Program::Module(module);
218 pass.process(&mut program);
219 match program {
220 ast::Program::Module(module) => module,
221 ast::Program::Script(_) => unreachable!("pass unexpectedly produced a script"),
222 }
223}
224
225pub fn transpile(
226 source: &str,
227 opts: TranspileOptions,
228) -> std::result::Result<TranspileOutput, TranspileError> {
229 let cm: Lrc<SourceMap> = Default::default();
230 let filename = opts
231 .filename
232 .clone()
233 .unwrap_or_else(|| "module.tsx".to_string());
234 let fm = cm.new_source_file(
235 FileName::Custom(filename.clone()).into(),
236 source.to_string(),
237 );
238
239 let handler = Handler::with_emitter_writer(Box::new(io::stderr()), Some(cm.clone()));
240
241 let globals = Globals::new();
242 let result = swc_core::common::GLOBALS.set(&globals, || {
243 let is_ts = filename.ends_with(".ts") || filename.ends_with(".tsx");
244 let is_jsx =
245 filename.ends_with(".jsx") || filename.ends_with(".tsx") || source.contains('<');
246 let syntax = if is_ts {
247 Syntax::Typescript(TsSyntax {
248 tsx: is_jsx,
249 ..Default::default()
250 })
251 } else {
252 Syntax::Es(EsSyntax {
253 jsx: is_jsx,
254 ..Default::default()
255 })
256 };
257 let lexer = Lexer::new(syntax, EsVersion::Es2022, StringInput::from(&*fm), None);
258 let mut parser = Parser::new_from(lexer);
259 let mut module = match parser.parse_module() {
260 Ok(m) => m,
261 Err(err) => {
262 let span = err.span();
263 let kind = err.kind().clone();
264 err.into_diagnostic(&handler).emit();
265 let loc = cm.lookup_char_pos(span.lo());
266 return Err(TranspileError::ParseError {
267 filename: filename.clone(),
268 line: loc.line,
269 col: loc.col.0 as usize + 1,
270 message: format!("{:?}", kind),
271 });
272 }
273 };
274
275 let unresolved = Mark::new();
276 let top_level = Mark::new();
277 module.visit_mut_with(&mut resolver(unresolved, top_level, false));
278
279 if is_ts {
280 module = run_module_pass(ts_strip(unresolved, top_level), module);
281 }
282
283 if is_jsx {
284 let react_cfg = react::Options {
285 development: Some(opts.react_dev),
286 runtime: Some(react::Runtime::Automatic),
287 import_source: Some("react".into()),
288 ..Default::default()
289 };
290 let pass = react::react(
291 cm.clone(),
292 None::<SingleThreadedComments>,
293 react_cfg,
294 top_level,
295 unresolved,
296 );
297 module = run_module_pass(pass, module);
298 }
299
300 module.visit_mut_with(&mut StaticImportRewriter);
303
304 module.visit_mut_with(&mut ImportRewriter);
306
307 if opts.to_commonjs {
308 let config = common_js::Config {
309 import_interop: Some(ImportInterop::Node),
310 ..Default::default()
311 };
312 let features = FeatureFlag {
313 support_block_scoping: true,
314 support_arrow: true,
315 };
316 module = run_module_pass(
317 common_js::common_js(Resolver::default(), unresolved, config, features),
318 module,
319 );
320 }
321
322 let mut buf = vec![];
323 {
324 let mut cfg = CodegenConfig::default();
325 cfg.target = EsVersion::Es2022;
326 cfg.minify = false;
327 let mut emitter = Emitter {
328 cfg,
329 comments: None,
330 cm: cm.clone(),
331 wr: JsWriter::new(cm.clone(), "\n", &mut buf, None),
332 };
333 if let Err(e) = emitter.emit_module(&module) {
334 return Err(TranspileError::CodegenError {
335 filename: filename.clone(),
336 source: anyhow::anyhow!(e),
337 });
338 }
339 }
340 let mut code = String::from_utf8(buf).unwrap_or_default();
341
342 if opts.to_commonjs {
349 use regex::Regex;
350 if let Ok(re_mod) = Regex::new(r"(?s)0\s*&&\s*module\.exports\s*=\s*(.*?);") {
352 code = re_mod
353 .replace_all(&code, |caps: ®ex::Captures| {
354 format!("0 && (module.exports = {});", &caps[1])
355 })
356 .into_owned();
357 }
358 if let Ok(re_exp) = Regex::new(r"(?s)0\s*&&\s*exports\.([A-Za-z_\$][A-Za-z0-9_\$]*)\s*=\s*(.*?);") {
359 code = re_exp
360 .replace_all(&code, |caps: ®ex::Captures| {
361 let name = &caps[1];
362 let rhs = &caps[2];
363 format!("0 && (exports.{} = {});", name, rhs)
364 })
365 .into_owned();
366 }
367 }
368 Ok(TranspileOutput { code, map: None })
369 });
370
371 match result {
372 Ok(out) => Ok(out),
373 Err(e) => Err(e),
374 }
375}
376
377pub fn version() -> &'static str {
378 env!("CARGO_PKG_VERSION")
379}
380
381#[cfg(all(target_os = "android", feature = "android"))]
382mod android_jni;
383
384#[cfg(target_vendor = "apple")]
385mod ios_ffi;
386
387#[cfg(feature = "wasm")]
389mod wasm_api {
390 use super::*;
391 use serde::Serialize;
392 use serde_wasm_bindgen::to_value;
393 use wasm_bindgen::prelude::*;
394
395 #[derive(Serialize)]
396 struct WasmTranspileResult {
397 code: Option<String>,
398 map: Option<String>,
399 error: Option<String>,
400 }
401
402 #[wasm_bindgen]
403 pub fn transpile_jsx(source: &str, filename: &str) -> JsValue {
404 let opts = TranspileOptions {
405 filename: Some(filename.to_string()),
406 react_dev: false,
407 to_commonjs: false,
408 pragma: None,
409 pragma_frag: None,
410 };
411 let result = match transpile(source, opts) {
412 Ok(out) => WasmTranspileResult {
413 code: Some(out.code),
414 map: out.map,
415 error: None,
416 },
417 Err(err) => WasmTranspileResult {
418 code: None,
419 map: None,
420 error: Some(err.to_string()),
421 },
422 };
423 to_value(&result)
424 .unwrap_or_else(|err| JsValue::from_str(&format!("serde-wasm-bindgen error: {err}")))
425 }
426
427 #[wasm_bindgen]
428 pub fn get_version() -> String {
429 version().to_string()
430 }
431}
432
433#[cfg(test)]
434mod tests {
435 use super::*;
436 use std::fs;
437 use std::path::PathBuf;
438 use swc_core::common::sync::Lrc;
439 use swc_core::common::{FileName, SourceMap};
440 use swc_core::ecma::ast::EsVersion;
441 use swc_core::ecma::parser::{EsSyntax, Parser, StringInput, Syntax, lexer::Lexer};
442
443 fn assert_parseable(code: &str) {
444 let cm: Lrc<SourceMap> = Default::default();
445 let source = code.to_string();
446 let fm = cm.new_source_file(FileName::Custom("transpiled.js".into()).into(), source);
447 let lexer = Lexer::new(
448 Syntax::Es(EsSyntax {
449 jsx: false,
450 ..Default::default()
451 }),
452 EsVersion::Es2022,
453 StringInput::from(&*fm),
454 None,
455 );
456 let mut parser = Parser::new_from(lexer);
457 parser
458 .parse_module()
459 .expect("transpiled output should parse");
460 }
461
462 #[test]
463 fn transpiles_basic_jsx() {
464 let src = "/** @jsx h */\nexport default function App(){ return <div>Hello</div> }";
465 let out = transpile(
466 src,
467 TranspileOptions {
468 filename: Some("app.jsx".into()),
469 react_dev: false,
470 to_commonjs: false,
471 pragma: Some("h".into()),
472 pragma_frag: None,
473 },
474 )
475 .unwrap();
476 assert!(out.code.contains("React.createElement") || out.code.contains("h("));
477 assert_parseable(&out.code);
478 }
479
480 #[test]
481 fn transpiles_to_commonjs_exports() {
482 let src = "/** @jsx h */\nexport default function App(){ return <div>Hello</div> }";
483 let out = transpile(
484 src,
485 TranspileOptions {
486 filename: Some("app.jsx".into()),
487 react_dev: false,
488 to_commonjs: true,
489 pragma: Some("h".into()),
490 pragma_frag: None,
491 },
492 )
493 .unwrap();
494 assert!(
495 out.code.contains("Object.defineProperty(exports"),
496 "commonjs output:\n{}",
497 out.code
498 );
499 assert!(
500 !out.code.contains("export default"),
501 "still exports after commonjs pass:\n{}",
502 out.code
503 );
504 assert_parseable(&out.code);
505 }
506
507 #[test]
508 fn rewrites_dynamic_import() {
509 let src = r#"async function x(){ const m = await import('./a.jsx'); return m }"#;
510 let out = transpile(
511 src,
512 TranspileOptions {
513 filename: Some("mod.jsx".into()),
514 react_dev: false,
515 to_commonjs: false,
516 pragma: None,
517 pragma_frag: None,
518 },
519 )
520 .unwrap();
521 assert!(out.code.contains("__hook_import"));
522 assert_parseable(&out.code);
523 }
524
525 #[test]
526 fn rewrites_static_special_imports() {
527 let src = r#"
528import React from 'react'
529import FileRenderer from '@relay/file-renderer'
530import helpers from '@relay/helpers'
531
532export default function App() {
533 return <div>test</div>
534}
535"#;
536 let out = transpile(
537 src,
538 TranspileOptions {
539 filename: Some("test.jsx".into()),
540 react_dev: false,
541 to_commonjs: false,
542 pragma: Some("h".into()),
543 pragma_frag: None,
544 },
545 )
546 .unwrap();
547 assert!(out.code.contains("const React = globalThis.__hook_react"), "expected React global rewrite");
548 assert!(out.code.contains("const FileRenderer = globalThis.__hook_file_renderer"), "expected FileRenderer global rewrite");
549 assert!(out.code.contains("const helpers = globalThis.__hook_helpers"), "expected helpers global rewrite");
550 assert!(!out.code.contains("import React from"), "should not contain original import");
551 assert_parseable(&out.code);
552 }
553
554 #[test]
555 fn transpiles_tmdb_plugin_commonjs() {
556 let path = fixture_path("../../template/hooks/client/plugin/tmdb.mjs");
557 if !path.exists() {
558 eprintln!(
559 "[test] Skipping tmdb.mjs transpile test: file not found at {:?}",
560 path
561 );
562 return;
563 }
564 let src = fs::read_to_string(&path).expect("read tmdb.mjs");
565 let out = transpile(
566 &src,
567 TranspileOptions {
568 filename: Some("tmdb.mjs".into()),
569 react_dev: false,
570 to_commonjs: true,
571 pragma: None,
572 pragma_frag: None,
573 },
574 )
575 .expect("transpile tmdb plugin to commonjs");
576 assert!(
577 out.code.contains("Object.defineProperty(exports")
578 || out.code.contains("exports.handleGetRequest"),
579 "commonjs output:\n{}",
580 out.code
581 );
582 assert!(
583 !out.code.contains("export "),
584 "commonjs output still emitted export keywords:\n{}",
585 out.code
586 );
587 }
592
593 #[test]
594 fn transpiles_get_client() {
595 let src = "/** @jsx h */\nexport default async function getClient(ctx){ const el = <div/>; const q = await import('./query-client.jsx'); return el }";
596 let out = transpile(
597 src,
598 TranspileOptions {
599 filename: Some("get-client.jsx".into()),
600 react_dev: true,
601 to_commonjs: false,
602 pragma: Some("h".into()),
603 pragma_frag: None,
604 },
605 )
606 .unwrap();
607 assert!(
608 out.code
609 .contains("__hook_import('./query-client.jsx')")
610 );
611 assert_parseable(&out.code);
612 }
613
614 #[test]
615 fn transpiles_real_get_client_file_if_present() {
616 let candidate = std::path::Path::new("../../template/hooks/client/get-client.jsx");
617 if !candidate.exists() {
618 eprintln!(
619 "[test] Skipping real get-client.jsx transpile test: file not found at {:?}",
620 candidate
621 );
622 return;
623 }
624 let src = std::fs::read_to_string(candidate).expect("read get-client.jsx");
625 let out = transpile(
626 &src,
627 TranspileOptions {
628 filename: Some("get-client.jsx".into()),
629 react_dev: true,
630 to_commonjs: false,
631 pragma: Some("h".into()),
632 pragma_frag: None,
633 },
634 )
635 .expect("transpile get-client.jsx");
636 assert!(
637 out.code.contains("__hook_import"),
638 "expected __hook_import usage"
639 );
640 assert!(
641 out.code.contains("React.createElement") || out.code.contains("h("),
642 "expected JSX transform"
643 );
644 assert_parseable(&out.code);
645 }
646
647 fn fixture_path(rel: &str) -> PathBuf {
648 PathBuf::from(env!("CARGO_MANIFEST_DIR")).join(rel)
649 }
650
651 #[test]
652 fn transpiles_query_client_fixture() {
653 let path = fixture_path("../../template/hooks/client/query-client.jsx");
654 if !path.exists() {
655 eprintln!(
656 "[test] Skipping query-client fixture test: {:?} does not exist",
657 path
658 );
659 return;
660 }
661 let src = fs::read_to_string(&path).expect("read query-client.jsx");
662 let out = transpile(
663 &src,
664 TranspileOptions {
665 filename: Some("query-client.jsx".into()),
666 react_dev: true,
667 to_commonjs: false,
668 pragma: Some("h".into()),
669 pragma_frag: None,
670 },
671 )
672 .expect("transpile query-client.jsx");
673 assert!(
674 out.code
675 .contains("__hook_import('./components/MovieResults.jsx')"),
676 "expected __hook_import for MovieResults"
677 );
678 assert!(
679 out.code.contains("__hook_import('./plugin/tmdb.mjs')"),
680 "expected __hook_import for tmdb plugin"
681 );
682 assert_parseable(&out.code);
683 }
684
685 #[test]
686 fn transpiles_layout_component_fixture() {
687 let path = fixture_path("../../template/hooks/client/components/Layout.jsx");
688 if !path.exists() {
689 eprintln!(
690 "[test] Skipping Layout fixture test: {:?} does not exist",
691 path
692 );
693 return;
694 }
695 let src = fs::read_to_string(&path).expect("read Layout.jsx");
696 let out = transpile(
697 &src,
698 TranspileOptions {
699 filename: Some("Layout.jsx".into()),
700 react_dev: false,
701 to_commonjs: false,
702 pragma: Some("h".into()),
703 pragma_frag: None,
704 },
705 )
706 .expect("transpile Layout.jsx");
707 assert!(out.code.contains("h("), "expected Layout output to call h");
708 assert_parseable(&out.code);
709 }
710}