Skip to main content

telar_macros/
lib.rs

1use proc_macro::TokenStream;
2use proc_macro2::{Ident, Span, TokenStream as TokenStream2};
3use quote::{ToTokens, quote};
4use std::path::PathBuf;
5
6mod app_input;
7mod t_macro;
8mod theme_tokens;
9use app_input::{AppInput, preview_const_ident};
10
11/// Implements [`Theme`](telar::Theme) and [`ThemeTokens`](telar::ThemeTokens) for a theme struct, mapping each
12/// token to the field of the same name.
13///
14/// A token whose built-in is a fixed value has to be answered — silence there is what puts a 4px radius next to
15/// bars the user configured to 10, on the same screen, with nothing failing. `radius_sm`/`_md`/`_lg` are exempt:
16/// they derive from `radius`, so a theme that moves the base takes the steps with it.
17///
18/// - `#[token(other)]` on a field: that field also answers `other`.
19/// - `#[theme(token = expr)]` on the struct: an expression, which may read `self`.
20/// - `#[theme(default(a, b))]` on the struct: keep the built-in, on purpose.
21#[proc_macro_derive(ThemeTokens, attributes(token, theme))]
22pub fn derive_theme_tokens(input: TokenStream) -> TokenStream {
23    let parsed = match syn::parse::<syn::DeriveInput>(input) {
24        Ok(parsed) => parsed,
25        Err(e) => return e.to_compile_error().into(),
26    };
27    match theme_tokens::expand(parsed) {
28        Ok(tokens) => tokens.into(),
29        Err(e) => e.to_compile_error().into(),
30    }
31}
32
33/// Translates a catalog key to a `String`, substituting named arguments: `t!("battery.remaining", time = t)`.
34///
35/// The key and its arguments are validated against the on-disk `locales/` catalog at compile time (an unknown
36/// key or wrong argument is a `compile_error!`). At runtime it reads the active locale reactively, so calling it
37/// inside a widget's content closure makes that widget re-render on a language switch.
38#[proc_macro]
39pub fn t(input: TokenStream) -> TokenStream {
40    match syn::parse::<t_macro::TInput>(input) {
41        Ok(parsed) => t_macro::expand(parsed).into(),
42        Err(e) => e.to_compile_error().into(),
43    }
44}
45
46#[proc_macro]
47pub fn app(input: TokenStream) -> TokenStream {
48    let AppInput {
49        theme_type,
50        setup,
51        config,
52        app_expr,
53    } = match syn::parse::<AppInput>(input) {
54        Ok(v) => v,
55        Err(e) => return e.to_compile_error().into(),
56    };
57
58    // The transpiler has no runtime access to the theme type, so it is passed as a source string; to_string inserts spaces around `::` so we collapse them for a valid turbofish.
59    let theme_type_str = theme_type
60        .to_token_stream()
61        .to_string()
62        .replace(" :: ", "::");
63
64    let TranspileOutput {
65        include_stmts,
66        rerun_stmts,
67        preview_const_idents,
68    } = match transpile_project(Some(theme_type_str.as_str())) {
69        Ok(o) => o,
70        Err(err) => return err.into(),
71    };
72
73    let preview_fn = quote! {
74        pub fn telar_all_preview_entries() -> ::std::vec::Vec<::telar::PreviewEntry> {
75            let mut entries = ::std::vec::Vec::new();
76            #( entries.extend_from_slice(#preview_const_idents); )*
77            entries
78        }
79    };
80
81    // Detected at macro expansion time: cargo-telar sets these env vars.
82    let is_hot_reload = hot_reload_build();
83    let is_preview = std::env::var("TELAR_PREVIEW_BUILD").is_ok();
84
85    // The env-var dispatch lives in `telar::dev_entry` rather than here, so an app that wires its own runner (`rsx_modules!` plus a hand-written `run()`) gets the same dev loop this macro generates.
86    let run_tail = quote! {
87        if ::telar::dev_entry(
88            telar_all_preview_entries,
89            ::telar::AppConfig::from(#config),
90            || #setup,
91        ) {
92            return;
93        }
94        #setup
95        ::telar::run_app_with_name(
96            ::telar::AppConfig::from(#config),
97            #app_expr,
98            env!("CARGO_PKG_NAME"),
99        )
100    };
101
102    let hot_reload_prefix = if is_hot_reload {
103        quote! {
104            if let (::std::result::Result::Ok(lib_path), ::std::result::Result::Ok(hot_port)) = (
105                ::std::env::var("TELAR_HOT_LIB"),
106                ::std::env::var("TELAR_HOT_PORT"),
107            ) {
108                #setup
109                ::telar::run_hot_reload_host(
110                    &lib_path,
111                    &hot_port,
112                    ::telar::AppConfig::from(#config),
113                    env!("CARGO_PKG_NAME"),
114                );
115                return;
116            }
117        }
118    } else {
119        quote! {}
120    };
121
122    let desktop_run = quote! {
123        #[cfg(not(target_os = "android"))]
124        pub fn run() {
125            #hot_reload_prefix
126            #run_tail
127        }
128    };
129
130    // Only emitted under TELAR_HOT_RELOAD_BUILD so dlopen can find the factory; TELAR_PREVIEW_BUILD lets the macro branch here without leaking a custom cfg into generated output (--cfg=telar_preview in RUSTFLAGS is only for cache-busting recompilation when switching modes).
131    let hot_export = if is_hot_reload {
132        let body: TokenStream2 = if is_preview {
133            quote! {
134                return ::std::boxed::Box::new(::telar::PreviewApp {
135                    entries: telar_all_preview_entries(),
136                });
137            }
138        } else {
139            quote! {
140                return ::std::boxed::Box::new(#app_expr);
141            }
142        };
143        quote! {
144            #[unsafe(no_mangle)]
145            pub unsafe extern "Rust" fn _rsx_hot_create_app() -> ::std::boxed::Box<dyn ::telar::App> {
146                #setup
147                #body
148            }
149        }
150    } else {
151        quote! {}
152    };
153
154    // Cleanup function exported for hot reload: called before dlclose to clean up TLS in the dylib.
155    let hot_cleanup = if is_hot_reload {
156        quote! {
157            #[unsafe(no_mangle)]
158            pub unsafe extern "Rust" fn _rsx_hot_cleanup() {
159                // Drop in-flight animations alongside the signals they target so none outlive this dylib's reset runtime.
160                ::telar::motion::reset();
161                // Drop pending task callbacks too: they are code compiled into this dylib, so running — or even dropping — one after dlclose would jump into unmapped memory.
162                ::telar::reset_tasks();
163                ::telar::reset_runtime();
164            }
165        }
166    } else {
167        quote! {}
168    };
169
170    // State-preservation symbols: the host snapshots the outgoing dylib's hot signals and restores them into the incoming one (see telar::hot_state).
171    let hot_state_symbols = if is_hot_reload {
172        quote! {
173            #[unsafe(no_mangle)]
174            pub unsafe extern "Rust" fn _rsx_hot_snapshot() -> ::std::string::String {
175                ::telar::hot_snapshot_json()
176            }
177            #[unsafe(no_mangle)]
178            pub unsafe extern "Rust" fn _rsx_hot_restore(blob: &str) {
179                ::telar::hot_restore_json(blob);
180            }
181        }
182    } else {
183        quote! {}
184    };
185
186    // Tree-ownership symbols: the dylib mounts and owns the segment tree, so its view effects are created in the
187    // same reactive runtime as the signals they read. Mounting it on the host's side instead leaves every
188    // subscription unestablished, which is what the force-tick workaround exists to paper over — see `telar::tree`.
189    let hot_tree_symbols = if is_hot_reload {
190        quote! {
191            #[unsafe(no_mangle)]
192            pub unsafe extern "Rust" fn _rsx_hot_tree_mount(
193                app: &dyn ::telar::App,
194            ) -> *mut ::telar::HotTree {
195                ::telar::HotTree::mount(app)
196            }
197            #[unsafe(no_mangle)]
198            pub unsafe extern "Rust" fn _rsx_hot_tree_release(tree: *mut ::telar::HotTree) {
199                unsafe { ::telar::HotTree::release(tree) }
200            }
201            #[unsafe(no_mangle)]
202            pub unsafe extern "Rust" fn _rsx_hot_tree_on_event(
203                tree: *mut ::telar::HotTree,
204                event: &::telar::Event,
205            ) -> bool {
206                unsafe { ::telar::HotTree::on_event(tree, event) }
207            }
208            #[unsafe(no_mangle)]
209            pub unsafe extern "Rust" fn _rsx_hot_tree_end_frame(tree: *mut ::telar::HotTree) {
210                unsafe { ::telar::HotTree::end_frame(tree) }
211            }
212            #[unsafe(no_mangle)]
213            pub unsafe extern "Rust" fn _rsx_hot_tree_paint(
214                tree: *mut ::telar::HotTree,
215            ) -> ::std::vec::Vec<::telar::DrawCommand> {
216                unsafe { ::telar::HotTree::paint(tree) }
217            }
218            #[unsafe(no_mangle)]
219            pub unsafe extern "Rust" fn _rsx_hot_tree_dirty(tree: *mut ::telar::HotTree) -> bool {
220                unsafe { ::telar::HotTree::is_dirty(tree) }
221            }
222            #[unsafe(no_mangle)]
223            pub unsafe extern "Rust" fn _rsx_hot_tree_generation(
224                tree: *mut ::telar::HotTree,
225            ) -> u64 {
226                unsafe { ::telar::HotTree::generation(tree) }
227            }
228            #[unsafe(no_mangle)]
229            pub unsafe extern "Rust" fn _rsx_hot_tree_walk(
230                tree: *mut ::telar::HotTree,
231            ) -> ::std::vec::Vec<::telar::SegmentNodeInfo> {
232                unsafe { ::telar::HotTree::walk(tree) }
233            }
234        }
235    } else {
236        quote! {}
237    };
238
239    // Motion-tick symbols: host and dylib link separate copies of motion-core, each with its own registry; the `Animated` values live in the dylib's, so the host must call across this boundary instead of ticking its own (empty) copy.
240    let hot_motion_symbols = if is_hot_reload {
241        quote! {
242            #[unsafe(no_mangle)]
243            pub unsafe extern "Rust" fn _rsx_hot_motion_tick(now: ::std::time::Instant) {
244                ::telar::motion::tick(now);
245            }
246            #[unsafe(no_mangle)]
247            pub unsafe extern "Rust" fn _rsx_hot_motion_active() -> bool {
248                ::telar::motion::has_active()
249            }
250            #[unsafe(no_mangle)]
251            pub unsafe extern "Rust" fn _rsx_hot_motion_continuous() -> bool {
252                ::telar::motion::has_continuous()
253            }
254            // Batch the dylib's reactive runtime around event dispatch: host and dylib link separate reactive-core copies, so the host must open/close the batch on the app's own runtime across this boundary — otherwise a handler's signal write flushes mid-dispatch and a segment loses its subscriptions while its widget is borrowed.
255            #[unsafe(no_mangle)]
256            pub unsafe extern "Rust" fn _rsx_hot_begin_batch() {
257                ::telar::begin_batch();
258            }
259            #[unsafe(no_mangle)]
260            pub unsafe extern "Rust" fn _rsx_hot_end_batch() {
261                ::telar::end_batch();
262            }
263            // Relayout the dylib's own layout runtime: the layout tree (taffy nodes) lives in the dylib's
264            // thread-local runtime, so the host must drive relayout across this boundary for a reactive
265            // list change to be laid out — its own copy is empty.
266            #[unsafe(no_mangle)]
267            pub unsafe extern "Rust" fn _rsx_hot_relayout() {
268                ::telar::relayout_if_dirty();
269            }
270            // Consult the dylib's own overlay registry: `overlay` widgets register in this dylib's
271            // thread-local, so the host must route pointer events to overlays (modal priority / background
272            // blocking) across this boundary — its own copy is empty.
273            #[unsafe(no_mangle)]
274            pub unsafe extern "Rust" fn _rsx_hot_dispatch_overlays(event: &::telar::Event) -> bool {
275                ::telar::dispatch_overlays(event)
276            }
277            // Write the OS light/dark preference into the dylib's own theme runtime (where `follow_system`'s
278            // effect lives), across the same boundary the host cannot reach directly.
279            #[unsafe(no_mangle)]
280            pub unsafe extern "Rust" fn _rsx_hot_set_system_dark(dark: bool) {
281                ::telar::set_system_dark(dark);
282            }
283            // Drain the dylib's own window-command queue: a title bar's `on_press` pushes into this dylib's
284            // thread-local, so the host must drain it across this boundary to apply drag/minimize/maximize/
285            // close — its own copy is empty.
286            #[unsafe(no_mangle)]
287            pub unsafe extern "Rust" fn _rsx_hot_drain_window_commands()
288            -> ::std::vec::Vec<::telar::WindowCommand> {
289                ::telar::take_window_commands()
290            }
291            // Run the completions of tasks spawned inside this dylib: `spawn_task` registers its callback in
292            // this dylib's reactive-core thread-local, so the host must drain it across this boundary — its
293            // own copy is empty.
294            #[unsafe(no_mangle)]
295            pub unsafe extern "Rust" fn _rsx_hot_drain_tasks() {
296                ::telar::drain_tasks();
297            }
298            // Give this dylib's reactive-core copy the loop wake, so a worker finishing in here runs a frame
299            // instead of waiting for the next input event.
300            #[unsafe(no_mangle)]
301            pub unsafe extern "Rust" fn _rsx_hot_install_task_waker(waker: ::telar::RedrawWaker) {
302                ::telar::set_task_waker(move || waker.wake());
303            }
304        }
305    } else {
306        quote! {}
307    };
308
309    let android_run = quote! {
310        #[cfg(target_os = "android")]
311        #[unsafe(no_mangle)]
312        fn android_main(android_app: ::telar::AndroidApp) {
313            #setup
314            ::telar::run_android_app_with_name(
315                ::telar::AppConfig::from(#config),
316                #app_expr,
317                env!("CARGO_PKG_NAME"),
318                android_app,
319            );
320        }
321    };
322
323    quote! {
324        #rerun_stmts
325        #include_stmts
326        #preview_fn
327        #desktop_run
328        #android_run
329        #hot_export
330        #hot_cleanup
331        #hot_state_symbols
332        #hot_tree_symbols
333        #hot_motion_symbols
334    }
335    .into()
336}
337
338// Set by cargo-telar for the dylib build. Cargo does not track env reads from a proc macro, so this must only ever select between outputs that are themselves distinguishable to cargo — here, two output directories.
339fn hot_reload_build() -> bool {
340    std::env::var("TELAR_HOT_RELOAD_BUILD").is_ok()
341}
342
343struct TranspileOutput {
344    include_stmts: TokenStream2,
345    rerun_stmts: TokenStream2,
346    preview_const_idents: Vec<Ident>,
347}
348
349// Transpiles every `.rsx` file under `src/` into `.telar/build/` (`.telar/build-hot/` for a hot-reload build), wiring each as a `#[path] mod` and aliasing
350// nested components to their basenames; also emits `include_str!` rerun triggers and (via `auto_modules`)
351// declares the hand-written `.rs` module tree. Shared by `app!` (which then adds the runner) and
352// `rsx_modules!` (transpilation only). `theme_type_str` types the transpiler's `use_theme` calls; pass `None`
353// when no theme is in scope. `Err` carries a `compile_error!` token stream for the caller to emit.
354fn transpile_project(theme_type_str: Option<&str>) -> Result<TranspileOutput, TokenStream2> {
355    let manifest_dir = std::env::var("CARGO_MANIFEST_DIR")
356        .map(PathBuf::from)
357        .map_err(|_| quote! { compile_error!("CARGO_MANIFEST_DIR not set") })?;
358
359    // A hot-reload build emits different code for the same `.rsx` (signals become `hot_signal_auto!`), so it needs its own output dir: sharing one has the two flavours — and the analyzer's live mirror, which always writes the plain one — overwrite each other's files on every build, leaving each cargo unit permanently stale.
360    let flavour = if hot_reload_build() {
361        "build-hot"
362    } else {
363        "build"
364    };
365    let generated_dir = manifest_dir.join(".telar").join(flavour);
366    if let Err(e) = std::fs::create_dir_all(&generated_dir) {
367        let msg = format!("Failed to create {}: {e}", generated_dir.display());
368        return Err(quote! { compile_error!(#msg) });
369    }
370
371    let src_dir = manifest_dir.join("src");
372    let rsx_files = telar_transpiler::find_rsx_files(&src_dir);
373    // Baked `src:"..."` asset paths resolve against one project asset root (default `./assets`), not each
374    // `.rsx`'s own directory — see `[telar] assets` in telar.toml.
375    let assets_root = telar_transpiler::assets_root(&manifest_dir);
376
377    // Pre-pass: collect every component's signature (its Props shape + whether it takes a slot) so each file's
378    // transpile can emit calls to other components correctly — optional props and the slot arg both need the
379    // callee's shape, which lives in another file.
380    let telar_transpiler::ProjectComponents {
381        registry,
382        borrowed: borrowed_files,
383        collision,
384    } = telar_transpiler::build_component_registry(
385        &src_dir,
386        &telar_transpiler::component_paths(&manifest_dir),
387        &[],
388    );
389    if let Some(msg) = collision {
390        return Err(quote! { compile_error!(#msg) });
391    }
392
393    let mut include_stmts = TokenStream2::new();
394    let mut rerun_stmts = TokenStream2::new();
395    let mut preview_const_idents: Vec<Ident> = Vec::new();
396    // Every path this run writes under `generated_dir`, so a stale file left behind by a renamed or deleted `.rsx` (or a toggled-off `auto_modules`/i18n catalog) can be told apart from live output and pruned.
397    let mut written_files: std::collections::HashSet<PathBuf> = std::collections::HashSet::new();
398
399    for rsx_file in &rsx_files {
400        let source = match std::fs::read_to_string(rsx_file) {
401            Ok(s) => s,
402            Err(e) => {
403                let msg = format!("Failed to read {}: {e}", rsx_file.display());
404                return Err(quote! { compile_error!(#msg) });
405            }
406        };
407
408        let stem = telar_transpiler::relative_stem(rsx_file, &src_dir);
409
410        let result = match telar_transpiler::transpile_source(
411            &source,
412            &stem,
413            theme_type_str,
414            Some(assets_root.as_path()),
415            Some(&registry),
416        ) {
417            Ok(r) => r,
418            Err(telar_transpiler::TranspileError::Parse(ref pe)) => {
419                let msg = format!("{}:{}: {}", rsx_file.display(), pe.line, pe.message);
420                return Err(quote! { compile_error!(#msg) });
421            }
422            Err(e) => {
423                let msg = format!("Failed to transpile {}: {e}", rsx_file.display());
424                return Err(quote! { compile_error!(#msg) });
425            }
426        };
427
428        // Mirror the source tree under .telar/build/ so files in different directories never collide. find_rsx_files only yields paths under src_dir, so None is unreachable here.
429        let Some(rel_out) = telar_transpiler::relative_output_path(rsx_file, &src_dir) else {
430            continue;
431        };
432        let out_path = generated_dir.join(rel_out);
433        written_files.insert(out_path.clone());
434        if let Some(parent) = out_path.parent() {
435            if let Err(e) = std::fs::create_dir_all(parent) {
436                let msg = format!("Failed to create {}: {e}", parent.display());
437                return Err(quote! { compile_error!(#msg) });
438            }
439        }
440
441        // Only write when content changed to avoid spurious recompilation.
442        let needs_write = std::fs::read_to_string(&out_path)
443            .map(|existing| existing != result.rust_code)
444            .unwrap_or(true);
445        if needs_write {
446            if let Err(e) = std::fs::write(&out_path, &result.rust_code) {
447                let msg = format!("Failed to write {}: {e}", out_path.display());
448                return Err(quote! { compile_error!(#msg) });
449            }
450        }
451
452        // Persist the source map next to the build file so the editor extension and `cargo telar check` can
453        // map rust-analyzer's and rustc's diagnostics on the generated Rust back onto the `.rsx` the author
454        // wrote — the lines, and the verbatim expression spans that make a column mean something.
455        let map_path = out_path.with_extension("rs.map");
456        let map_json =
457            telar_transpiler::SourceMap::new(result.source_map.clone(), result.expr_spans.clone())
458                .to_json();
459        let map_stale = std::fs::read_to_string(&map_path)
460            .map(|existing| existing != map_json)
461            .unwrap_or(true);
462        if map_stale {
463            let _ = std::fs::write(&map_path, &map_json);
464        }
465
466        // Wire each generated file as a real `#[path] mod` (not `include!`) so rust-analyzer treats it as a
467        // first-class module and offers completion inside it; `pub use` keeps the component fns, preview consts
468        // and `Props` types reachable by bare name, exactly as `include!` did.
469        let out_path_str = out_path.to_string_lossy().to_string();
470        let mod_ident = Ident::new(
471            &format!(
472                "__rsx_mod_{}",
473                telar_transpiler::naming::to_snake_case(&stem)
474            ),
475            Span::call_site(),
476        );
477        include_stmts.extend(quote! {
478            #[path = #out_path_str]
479            mod #mod_ident;
480            #[allow(unused_imports)]
481            pub use #mod_ident::*;
482        });
483
484        // Let a nested component be referenced in markup by its bare file name, not its path-flattened name:
485        // alias the path-derived fn (and Props type) to the basename at crate root. Skipped for files directly
486        // under src/ (basename already equals the full name).
487        let base_name = rsx_file
488            .file_stem()
489            .map(|s| s.to_string_lossy().to_string())
490            .unwrap_or_default();
491        let base_fn = telar_transpiler::naming::to_snake_case(&base_name);
492        let full_fn = telar_transpiler::naming::to_snake_case(&stem);
493        if !base_fn.is_empty() && base_fn != full_fn {
494            let full_fn_ident = Ident::new(&full_fn, Span::call_site());
495            let base_fn_ident = Ident::new(&base_fn, Span::call_site());
496            include_stmts.extend(quote! {
497                #[allow(unused_imports)]
498                pub use #mod_ident::#full_fn_ident as #base_fn_ident;
499            });
500            if result.has_props {
501                let full_props = Ident::new(
502                    &(telar_transpiler::naming::to_pascal_case(&full_fn) + "Props"),
503                    Span::call_site(),
504                );
505                let base_props = Ident::new(
506                    &(telar_transpiler::naming::to_pascal_case(&base_fn) + "Props"),
507                    Span::call_site(),
508                );
509                include_stmts.extend(quote! {
510                    #[allow(unused_imports)]
511                    pub use #mod_ident::#full_props as #base_props;
512                });
513            }
514        }
515
516        let rsx_path_str = rsx_file.to_string_lossy().to_string();
517        rerun_stmts.extend(quote! { const _: &str = include_str!(#rsx_path_str); });
518
519        if !result.preview_names.is_empty() {
520            preview_const_idents.push(preview_const_ident(&stem));
521        }
522    }
523
524    // Opt-in via `[telar] auto_modules = true` in telar.toml: declare the hand-written `.rs` modules by walking the
525    // source tree, so an app needs no `mod.rs`/`mod` statements for them — mirroring how `.rsx` files are wired.
526    // A borrowed component's signature is baked into this crate's call sites, so editing its `Props` in the crate that owns it has to rebuild this one too — otherwise the call keeps the old arity and fails in generated code with nothing pointing at the file that moved.
527    for rsx_file in &borrowed_files {
528        let borrowed_str = rsx_file.to_string_lossy().to_string();
529        rerun_stmts.extend(quote! { const _: &str = include_str!(#borrowed_str); });
530    }
531
532    let telar_toml = manifest_dir.join("telar.toml");
533    if telar_toml.exists() {
534        // Re-run the macro when telar.toml changes (e.g. toggling auto_modules), like the `.rsx` sources.
535        let telar_toml_str = telar_toml.to_string_lossy().to_string();
536        rerun_stmts.extend(quote! { const _: &str = include_str!(#telar_toml_str); });
537    }
538    if telar_transpiler::auto_modules_enabled(&manifest_dir) {
539        // The discovered tree is split across real generated files (one per directory) so every module is a
540        // file-based `#[path] mod`; see `discover_rust_modules` for why inline `mod` blocks break rust-analyzer.
541        let modtree_dir = generated_dir.join("__modules");
542        if let Err(e) = std::fs::create_dir_all(&modtree_dir) {
543            let msg = format!("Failed to create {}: {e}", modtree_dir.display());
544            return Err(quote! { compile_error!(#msg) });
545        }
546        let (modules_src, modtree_written) =
547            match telar_transpiler::discover_rust_modules(&src_dir, &modtree_dir) {
548                Ok(s) => s,
549                Err(e) => {
550                    let msg = format!("Failed to write the auto-discovered module tree: {e}");
551                    return Err(quote! { compile_error!(#msg) });
552                }
553            };
554        written_files.extend(modtree_written);
555        match modules_src.parse::<TokenStream2>() {
556            Ok(tokens) => include_stmts.extend(tokens),
557            Err(e) => {
558                let msg = format!("Failed to emit auto-discovered modules: {e}");
559                return Err(quote! { compile_error!(#msg) });
560            }
561        }
562    }
563
564    // Bake the i18n catalog when a `locales/` directory exists: parse every `locales/<tag>.toml` into one
565    // generated module wired at the crate root, so `t!`/markup call sites reference `crate::__rsx_i18n::CATALOG`.
566    // Inert (nothing generated) when there is no catalog, mirroring how svg baking only fires for `svg` elements.
567    match telar_transpiler::parse_catalog(&manifest_dir) {
568        Ok(Some(catalog)) => {
569            let src = telar_transpiler::bake_catalog_to_source(&catalog);
570            let out_path = generated_dir.join("__i18n.rs");
571            written_files.insert(out_path.clone());
572            let needs_write = std::fs::read_to_string(&out_path)
573                .map(|existing| existing != src)
574                .unwrap_or(true);
575            if needs_write && let Err(e) = std::fs::write(&out_path, &src) {
576                let msg = format!("Failed to write {}: {e}", out_path.display());
577                return Err(quote! { compile_error!(#msg) });
578            }
579            let out_path_str = out_path.to_string_lossy().to_string();
580            let mod_ident = Ident::new(telar_transpiler::I18N_MODULE, Span::call_site());
581            include_stmts.extend(quote! {
582                #[path = #out_path_str]
583                #[allow(dead_code)]
584                pub mod #mod_ident;
585            });
586            // Re-bake when any locale file changes, like a `.rsx` edit.
587            for file in telar_transpiler::catalog_files(&manifest_dir) {
588                let path_str = file.to_string_lossy().to_string();
589                rerun_stmts.extend(quote! { const _: &str = include_str!(#path_str); });
590            }
591        }
592        Ok(None) => {}
593        Err(msg) => return Err(quote! { compile_error!(#msg) }),
594    }
595
596    // Only reached once the whole project transpiled without error, so `written_files` is complete: anything else under `generated_dir` is what an earlier run wrote for a `.rsx` (or a feature) that is gone now.
597    telar_transpiler::prune_stale_generated(&generated_dir, &written_files);
598
599    Ok(TranspileOutput {
600        include_stmts,
601        rerun_stmts,
602        preview_const_idents,
603    })
604}
605
606/// Transpile every `.rsx` file under `src/` and declare the module tree — what `app!` does, minus the winit
607/// runner. Use this in a crate that drives rsx through a **custom** `Platform` (e.g. a Wayland layer-shell
608/// backend) instead of the built-in desktop runner: invoke `telar::rsx_modules!()` at the crate root, then build
609/// your own `App` from the transpiled components and run it via `telar::run_with_platform` /
610/// `telar::run_multi_with_platform`. Pass a theme type — `rsx_modules!(MyTheme)` — if your `.rsx` calls
611/// `use_theme`; otherwise `rsx_modules!()`.
612#[proc_macro]
613pub fn rsx_modules(input: TokenStream) -> TokenStream {
614    let theme_type_str = if input.is_empty() {
615        None
616    } else {
617        match syn::parse::<syn::Path>(input) {
618            Ok(path) => Some(path.to_token_stream().to_string().replace(" :: ", "::")),
619            Err(e) => return e.to_compile_error().into(),
620        }
621    };
622    let TranspileOutput {
623        include_stmts,
624        rerun_stmts,
625        preview_const_idents,
626    } = match transpile_project(theme_type_str.as_deref()) {
627        Ok(o) => o,
628        Err(err) => return err.into(),
629    };
630    let preview_fn = quote! {
631        pub fn telar_all_preview_entries() -> ::std::vec::Vec<::telar::PreviewEntry> {
632            let mut entries = ::std::vec::Vec::new();
633            #( entries.extend_from_slice(#preview_const_idents); )*
634            entries
635        }
636    };
637    quote! {
638        #rerun_stmts
639        #include_stmts
640        #preview_fn
641    }
642    .into()
643}