Skip to main content

euv_cli/build/
fn.rs

1use super::*;
2
3/// Checks whether `wasm_pack_args` already contains a build mode flag.
4///
5/// Returns `true` if any of `--dev`, `--release`, or `--profiling`
6/// is present in the arguments list.
7///
8/// # Arguments
9///
10/// - `&[String]` - The wasm-pack arguments to search.
11///
12/// # Returns
13///
14/// - `bool` - Whether a build mode flag is already present.
15pub fn has_build_mode_flag(wasm_pack_args: &[String]) -> bool {
16    wasm_pack_args
17        .iter()
18        .any(|arg: &String| arg == DEV_FLAG || arg == RELEASE_FLAG || arg == PROFILING_FLAG)
19}
20
21/// Filters out euv-specific arguments from the wasm-pack arguments.
22///
23/// First locates the genuine passthrough arguments by taking everything
24/// after the last `--` separator (or the full list if no `--` is present).
25/// Then removes all known euv-specific flags and their values so that
26/// only wasm-pack-compatible arguments remain.
27///
28/// # Arguments
29///
30/// - `&[String]` - The raw wasm-pack arguments to filter.
31///
32/// # Returns
33///
34/// - `Vec<String>` - The filtered arguments safe for wasm-pack.
35pub fn filter_euv_args(wasm_pack_args: &[String]) -> Vec<String> {
36    let raw_args: &[String] = if let Some(position) = wasm_pack_args
37        .iter()
38        .rposition(|arg: &String| arg == DOUBLE_DASH)
39    {
40        &wasm_pack_args[position + 1..]
41    } else {
42        wasm_pack_args
43    };
44    let mut filtered: Vec<String> = Vec::new();
45    let mut skip_next: bool = false;
46    for arg in raw_args {
47        if skip_next {
48            skip_next = false;
49            continue;
50        }
51        // Compare the flag NAME, not the whole argument: clap accepts both
52        // `--port 3000` and `--port=3000`, and only the first form ever
53        // matched `EUV_ARGS` exactly. Without the split, `--port=3000` was
54        // forwarded to wasm-pack even though `reconcile_args` had already
55        // consumed its value — the same string both used and leaked.
56        let name: &str = arg.split('=').next().unwrap_or(arg.as_str());
57        if EUV_ARGS.contains(&name) {
58            if arg.contains('=') || name == NO_GITIGNORE_ARG {
59                // `--no-gitignore` is a bare boolean: there is no value to
60                // skip, so treating it like `--port 3000` ate whatever
61                // argument followed it.
62                continue;
63            }
64            skip_next = true;
65            continue;
66        }
67        filtered.push(arg.clone());
68    }
69    filtered
70}
71
72/// Reconciles euv-specific arguments that may have been collected into
73/// `wasm_pack_args` (e.g. when placed after `--`) back into the
74/// corresponding `ModeArgs` fields.
75///
76/// Because clap's `trailing_var_arg` collects all unrecognized arguments
77/// into `wasm_pack_args`, any euv flag placed after `--` is not parsed
78/// by clap into its dedicated field. This function scans `wasm_pack_args`
79/// for known euv flags and overwrites the `ModeArgs` fields so that the
80/// rest of the codebase can rely on the typed accessors regardless of
81/// argument order.
82///
83/// # Arguments
84///
85/// - `&mut ModeArgs` - The CLI arguments to reconcile in-place.
86pub fn reconcile_args(args: &mut ModeArgs) {
87    let wasm_pack_args: Vec<String> = args.get_wasm_pack_args().clone();
88    let mut crate_path: Option<PathBuf> = None;
89    let mut port: Option<u16> = None;
90    let mut www_dir: Option<String> = None;
91    let mut index_html: Option<Option<PathBuf>> = None;
92    let mut no_gitignore: Option<bool> = None;
93    let mut dev: Option<bool> = None;
94    let mut release: Option<bool> = None;
95    let mut profiling: Option<bool> = None;
96    let mut iter: Iter<String> = wasm_pack_args.iter();
97    while let Some(arg) = iter.next() {
98        match arg.as_str() {
99            CRATE_PATH_ARG | CRATE_PATH_ARG_SHORT => {
100                if let Some(value) = iter.next() {
101                    crate_path = Some(PathBuf::from(value));
102                }
103            }
104            PORT_ARG | PORT_ARG_SHORT => {
105                if let Some(value) = iter.next()
106                    && let Ok(parsed_port) = value.parse::<u16>()
107                {
108                    port = Some(parsed_port);
109                }
110            }
111            WWW_DIR_ARG => {
112                if let Some(value) = iter.next() {
113                    www_dir = Some(value.clone());
114                }
115            }
116            INDEX_HTML_ARG => {
117                if let Some(value) = iter.next() {
118                    index_html = Some(Some(PathBuf::from(value)));
119                }
120            }
121            NO_GITIGNORE_ARG => {
122                no_gitignore = Some(true);
123            }
124            DEV_FLAG => {
125                dev = Some(true);
126            }
127            RELEASE_FLAG => {
128                release = Some(true);
129            }
130            PROFILING_FLAG => {
131                profiling = Some(true);
132            }
133            other => {
134                if let Some(value) = other.strip_prefix(&format!("{CRATE_PATH_ARG}=")) {
135                    crate_path = Some(PathBuf::from(value));
136                } else if let Some(value) = other.strip_prefix(&format!("{PORT_ARG}=")) {
137                    if let Ok(parsed_port) = value.parse::<u16>() {
138                        port = Some(parsed_port);
139                    }
140                } else if let Some(value) = other.strip_prefix(&format!("{WWW_DIR_ARG}=")) {
141                    www_dir = Some(value.to_string());
142                } else if let Some(value) = other.strip_prefix(&format!("{INDEX_HTML_ARG}=")) {
143                    index_html = Some(Some(PathBuf::from(value)));
144                }
145            }
146        }
147    }
148    if let Some(value) = crate_path {
149        args.set_crate_path(value);
150    }
151    if let Some(value) = port {
152        args.set_port(value);
153    }
154    if let Some(value) = www_dir {
155        args.set_www_dir(value);
156    }
157    if let Some(value) = index_html {
158        args.set_index_html(value);
159    }
160    if let Some(value) = no_gitignore {
161        args.set_no_gitignore(value);
162    }
163    if let Some(value) = dev {
164        args.set_dev(value);
165    }
166    if let Some(value) = release {
167        args.set_release(value);
168    }
169    if let Some(value) = profiling {
170        args.set_profiling(value);
171    }
172}
173
174/// Resolves the build mode from CLI arguments.
175///
176/// First checks the explicit `--dev`, `--release`, and `--profiling` flags on `ModeArgs`.
177/// If none of those are set, inspects `wasm_pack_args` for any build mode flag
178/// that may have been forwarded by the user.
179/// Defaults to `BuildMode::Dev` if no build mode flag is found anywhere.
180///
181/// # Arguments
182///
183/// - `&ModeArgs` - The CLI arguments containing the build mode flags and wasm_pack_args.
184///
185/// # Returns
186///
187/// - `BuildMode` - The resolved build mode.
188pub fn resolve_build_mode(args: &ModeArgs) -> BuildMode {
189    if args.get_profiling() {
190        BuildMode::Profiling
191    } else if args.get_release() {
192        BuildMode::Release
193    } else if args.get_dev() {
194        BuildMode::Dev
195    } else if args
196        .get_wasm_pack_args()
197        .iter()
198        .any(|arg: &String| arg == PROFILING_FLAG)
199    {
200        BuildMode::Profiling
201    } else if args
202        .get_wasm_pack_args()
203        .iter()
204        .any(|arg: &String| arg == RELEASE_FLAG)
205    {
206        BuildMode::Release
207    } else {
208        BuildMode::Dev
209    }
210}
211
212/// Converts a `BuildMode` to the corresponding wasm-pack flag string.
213///
214/// # Arguments
215///
216/// - `BuildMode` - The build mode to convert.
217///
218/// # Returns
219///
220/// - `&'static str` - The wasm-pack command-line flag.
221pub fn build_mode_to_flag(build_mode: BuildMode) -> &'static str {
222    match build_mode {
223        BuildMode::Dev => DEV_FLAG,
224        BuildMode::Release => RELEASE_FLAG,
225        BuildMode::Profiling => PROFILING_FLAG,
226    }
227}
228
229/// Builds a `Gitignore` matcher from the `.gitignore` file at the given root path.
230///
231/// # Arguments
232///
233/// - `&PathBuf` - The root directory where `.gitignore` is located.
234///
235/// # Returns
236///
237/// - `Gitignore` - The compiled gitignore matcher.
238async fn build_gitignore(root: &PathBuf) -> Gitignore {
239    let gitignore_path: PathBuf = root.join(GITIGNORE_FILE_NAME);
240    let mut builder: GitignoreBuilder = GitignoreBuilder::new(root);
241    let gitignore_exists: bool = metadata(&gitignore_path).await.is_ok();
242    if gitignore_exists && let Some(error) = builder.add(&gitignore_path) {
243        log::warn!("Failed to load .gitignore: {error}");
244    }
245    match builder.build() {
246        Ok(gitignore) => {
247            if gitignore_exists {
248                log::info!("Loaded .gitignore to filter file change events");
249            }
250            gitignore
251        }
252        Err(error) => {
253            log::warn!("Failed to build gitignore matcher: {error}");
254            GitignoreBuilder::new(root)
255                .build()
256                .unwrap_or_else(|_error: ignore::Error| Gitignore::empty())
257        }
258    }
259}
260
261/// Extracts the value of `--out-name` from the wasm-pack arguments.
262///
263/// Returns `None` if `--out-name` is not specified.
264///
265/// # Arguments
266///
267/// - `&[String]` - The wasm-pack arguments to search.
268///
269/// # Returns
270///
271/// - `Option<String>` - The value of `--out-name` if found.
272fn extract_out_name(wasm_pack_args: &[String]) -> Option<String> {
273    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
274    while let Some(arg) = iter.next() {
275        if arg == OUT_NAME_ARG {
276            return iter.next().cloned();
277        }
278        if let Some(value) = arg.strip_prefix(&format!("{OUT_NAME_ARG}=")) {
279            return Some(value.to_string());
280        }
281    }
282    None
283}
284
285/// Extracts the value of `--out-dir` from the wasm-pack arguments.
286///
287/// Returns `None` if `--out-dir` is not specified.
288///
289/// # Arguments
290///
291/// - `&[String]` - The wasm-pack arguments to search.
292///
293/// # Returns
294///
295/// - `Option<String>` - The value of `--out-dir` if found.
296fn extract_out_dir(wasm_pack_args: &[String]) -> Option<String> {
297    let mut iter: Iter<'_, String> = wasm_pack_args.iter();
298    while let Some(arg) = iter.next() {
299        if arg == OUT_DIR_ARG {
300            return iter.next().cloned();
301        }
302        if let Some(value) = arg.strip_prefix(&format!("{OUT_DIR_ARG}=")) {
303            return Some(value.to_string());
304        }
305    }
306    None
307}
308
309/// Resolves the output JS filename for HTML generation.
310///
311/// Uses `--out-name` from wasm-pack args if specified,
312/// otherwise reads the crate name from `Cargo.toml` `[package] name` field
313/// and replaces hyphens with underscores (matching wasm-pack behavior).
314/// Appends `.js` extension to form the complete JS filename.
315///
316/// # Arguments
317///
318/// - `&ModeArgs` - The CLI arguments containing crate_path and wasm_pack_args.
319///
320/// # Returns
321///
322/// - `String` - The resolved JS filename with `.js` extension (e.g. `euv_example.js`).
323pub fn resolve_out_name(args: &ModeArgs) -> String {
324    let name: String = if let Some(out_name) = extract_out_name(args.get_wasm_pack_args()) {
325        out_name.replace(STR_HYPHEN, STR_UNDERSCORE)
326    } else {
327        let cargo_toml_path: PathBuf = args.get_crate_path().join(CARGO_TOML_FILE_NAME);
328        read_crate_name_from_toml(&cargo_toml_path)
329            .unwrap_or_else(|| {
330                args.get_crate_path()
331                    .file_name()
332                    .unwrap_or_default()
333                    .to_string_lossy()
334                    .to_string()
335            })
336            .replace(STR_HYPHEN, STR_UNDERSCORE)
337    };
338    format!("{name}{JS_EXTENSION}")
339}
340
341/// Reads the `name` field from a Cargo.toml file.
342///
343/// Parses the file line-by-line looking for `name = "..."` within the `[package]` section.
344///
345/// # Arguments
346///
347/// - `&Path` - The path to the Cargo.toml file.
348///
349/// # Returns
350///
351/// - `Option<String>` - The crate name if found.
352fn read_crate_name_from_toml(path: &Path) -> Option<String> {
353    let content: String = sync_read_to_string(path).ok()?;
354    let mut in_package: bool = false;
355    for line in content.lines() {
356        let trimmed: &str = line.trim();
357        if trimmed.starts_with('[') {
358            in_package = trimmed == "[package]";
359            continue;
360        }
361        if in_package
362            && trimmed.starts_with("name")
363            && let Some(value) = trimmed.strip_prefix("name")
364        {
365            let value: &str = value.trim().strip_prefix('=')?.trim();
366            let value: &str = value.strip_prefix('"')?.strip_suffix('"')?;
367            return Some(value.to_string());
368        }
369    }
370    None
371}
372
373/// Computes the relative path from a base directory to a target directory.
374///
375/// Compares the component sequences of both paths to find the common prefix,
376/// then emits `..` for each remaining base component followed by the remaining
377/// target components.
378///
379/// # Arguments
380///
381/// - `&Path` - The base directory path.
382/// - `&Path` - The target directory path.
383///
384/// # Returns
385///
386/// - `PathBuf` - The relative path from base to target.
387fn compute_relative_path(base: &Path, target: &Path) -> PathBuf {
388    let base_components: Vec<Component> = base.components().collect();
389    let target_components: Vec<Component> = target.components().collect();
390    let common_len: usize = base_components
391        .iter()
392        .zip(target_components.iter())
393        .take_while(
394            |(base_component, target_component): &(&Component, &Component)| {
395                base_component == target_component
396            },
397        )
398        .count();
399    let mut result: PathBuf = PathBuf::new();
400    for _ in &base_components[common_len..] {
401        result.push("..");
402    }
403    for component in &target_components[common_len..] {
404        if let Component::Normal(os_str) = component {
405            result.push(os_str);
406        }
407    }
408    result
409}
410
411/// Resolves the serving root directory for the development server.
412///
413/// When the output directory is inside the www directory, returns the resolved www directory.
414/// When the output directory is outside the www directory, returns the parent of the output directory
415/// so that `index.html` and WASM artifacts are co-located under the same serving root.
416///
417/// # Arguments
418///
419/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
420///
421/// # Returns
422///
423/// - `PathBuf` - The resolved serving root directory.
424pub async fn resolve_serving_root(args: &ModeArgs) -> PathBuf {
425    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
426    let out_dir_absolute: PathBuf = resolve_out_dir(args);
427    if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
428        resolve_www_dir(&www_absolute).await
429    } else {
430        out_dir_absolute
431            .parent()
432            .map(|p: &Path| p.to_path_buf())
433            .unwrap_or_else(|| www_absolute)
434    }
435}
436
437/// Resolves the serving route prefix relative to the crate path.
438///
439/// Returns the forward-slash-separated path of the serving root relative to the crate path.
440/// Used for server route registration and URL display.
441///
442/// # Arguments
443///
444/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
445///
446/// # Returns
447///
448/// - `String` - The serving route prefix (e.g. `www` or `wwws`).
449pub fn resolve_serving_route_prefix(args: &ModeArgs) -> String {
450    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
451    let out_dir_absolute: PathBuf = resolve_out_dir(args);
452    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
453        www_absolute
454    } else {
455        out_dir_absolute
456            .parent()
457            .map(|p: &Path| p.to_path_buf())
458            .unwrap_or_else(|| www_absolute)
459    };
460    serving_root
461        .strip_prefix(args.get_crate_path())
462        .map(|rel: &Path| {
463            rel.to_string_lossy()
464                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
465        })
466        .unwrap_or_else(|_| {
467            args.get_www_dir()
468                .replace(CHAR_SLASH_BACK, STR_SLASH_FORWARD)
469        })
470}
471
472/// Resolves the JS import path for HTML generation.
473///
474/// Computes the relative path from the serving root to the output directory,
475/// then appends the JS filename (from `resolve_out_name`, which includes `.js`)
476/// to form the full import path (e.g. `./pkg/euv.js` or `./pksg/cc.js`).
477///
478/// # Arguments
479///
480/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, wasm_pack_args.
481///
482/// # Returns
483///
484/// - `String` - The resolved JS import path relative to the serving root.
485pub fn resolve_import_path(args: &ModeArgs) -> String {
486    let out_name: String = resolve_out_name(args);
487    let www_absolute: PathBuf = args.get_crate_path().join(args.get_www_dir());
488    let out_dir_absolute: PathBuf = resolve_out_dir(args);
489    let serving_root: PathBuf = if out_dir_absolute.strip_prefix(&www_absolute).is_ok() {
490        www_absolute
491    } else {
492        out_dir_absolute
493            .parent()
494            .map(|p: &Path| p.to_path_buf())
495            .unwrap_or_else(|| www_absolute)
496    };
497    let relative: PathBuf = compute_relative_path(&serving_root, &out_dir_absolute);
498    let mut components: Vec<String> = relative
499        .components()
500        .filter_map(|component: Component| match component {
501            Component::Normal(os_str) => os_str.to_str().map(|text: &str| text.to_string()),
502            Component::ParentDir => Some(PARENT_DIR.to_string()),
503            _ => None,
504        })
505        .collect();
506    components.push(out_name);
507    format!("{RELATIVE_PATH_PREFIX}{}", components.join(PATH_SEPARATOR))
508}
509
510/// Resolves the output directory for wasm-pack artifacts.
511///
512/// Uses `--out-dir` from wasm-pack args if specified,
513/// otherwise defaults to `{www_dir}/pkg` so that build artifacts
514/// are placed directly inside the www directory served
515/// by the development server.
516///
517/// # Arguments
518///
519/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
520///
521/// # Returns
522///
523/// - `PathBuf` - The resolved output directory (absolute if crate_path is joined).
524pub fn resolve_out_dir(args: &ModeArgs) -> PathBuf {
525    let out_dir_path: PathBuf = PathBuf::from(
526        extract_out_dir(args.get_wasm_pack_args())
527            .unwrap_or_else(|| format!("{}/{PKG_DIR_NAME}", args.get_www_dir())),
528    );
529    if out_dir_path.is_absolute() {
530        out_dir_path
531    } else {
532        args.get_crate_path().join(&out_dir_path)
533    }
534}
535
536/// Executes a build-only pipeline: formats euv macros, cleans output directory,
537/// builds WASM, and generates HTML.
538///
539/// Unlike `run_build_pipeline`, this cleans the output directory before building
540/// and skips reload notifications — only the essential WASM build artifacts are kept.
541///
542/// # Arguments
543///
544/// - `&ModeArgs` - The CLI arguments.
545///
546/// # Returns
547///
548/// - `Result<(), EuvError>` - Indicates success or failure of the build.
549pub async fn run_build_only_pipeline(args: &ModeArgs) -> Result<(), EuvError> {
550    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
551    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
552        log::warn!("euv fmt error: {error}");
553    }
554    let out_dir: PathBuf = resolve_out_dir(args);
555    clean_out_dir(&out_dir).await;
556    build_wasm(args).await?;
557    log::info!("WASM build completed successfully");
558    let html_config: HtmlConfig = HtmlConfig::new(
559        resolve_serving_root(args).await,
560        resolve_import_path(args),
561        resolve_build_mode(args) == BuildMode::Release,
562        args.try_get_index_html().clone(),
563        false,
564    );
565    generate_html(&html_config).await?;
566    Ok(())
567}
568
569/// Cleans the output directory before a fresh build.
570///
571/// Removes all files and subdirectories within the output directory
572/// so that stale artifacts from previous builds do not remain.
573/// The directory itself is preserved (recreated if missing).
574///
575/// # Arguments
576///
577/// - `&Path` - The output directory to clean.
578pub async fn clean_out_dir(out_dir: &Path) {
579    let mut entries: ReadDir = match read_dir(out_dir).await {
580        Ok(dir) => dir,
581        Err(_) => return,
582    };
583    while let Ok(Some(entry)) = entries.next_entry().await {
584        let path: PathBuf = entry.path();
585        if path.is_dir() {
586            if let Err(error) = remove_dir_all(&path).await {
587                log::warn!("Failed to remove directory '{}': {error}", path.display());
588            }
589        } else if let Err(error) = remove_file(&path).await {
590            log::warn!("Failed to remove file '{}': {error}", path.display());
591        }
592    }
593}
594
595/// Executes a full build pipeline: euv fmt, build wasm, generate HTML.
596/// Notifies the reload channel on build success or failure.
597///
598/// # Arguments
599///
600/// - `&ModeArgs` - The CLI arguments.
601/// - `Option<&broadcast::Sender<ReloadEvent>>` - Optional reload channel for notifying clients.
602///
603/// # Returns
604///
605/// - `Result<String, EuvError>` - The generated HTML with reload script injected on success.
606pub async fn run_build_pipeline(
607    args: &ModeArgs,
608    reload_tx: Option<&broadcast::Sender<ReloadEvent>>,
609) -> Result<String, EuvError> {
610    let src_path: PathBuf = args.get_crate_path().join(SRC_DIR_NAME);
611    if let Err(error) = format_dir(&src_path, FmtMode::Write).await {
612        log::warn!("euv fmt error: {error}");
613    }
614    match build_wasm(args).await {
615        Ok(()) => {
616            log::info!("WASM build completed successfully");
617            if let Some(sender) = reload_tx {
618                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
619                    sender.send(ReloadEvent::Reload);
620            }
621        }
622        Err(error) => {
623            log::error!("WASM build failed: {error}");
624            if let Some(sender) = reload_tx {
625                let _: Result<usize, tokio::sync::broadcast::error::SendError<ReloadEvent>> =
626                    sender.send(ReloadEvent::Error(error.to_string()));
627            }
628        }
629    }
630    let html_config: HtmlConfig = HtmlConfig::new(
631        resolve_serving_root(args).await,
632        resolve_import_path(args),
633        resolve_build_mode(args) == BuildMode::Release,
634        args.try_get_index_html().clone(),
635        true,
636    );
637    let html: String = generate_html(&html_config).await?;
638    Ok(html)
639}
640
641/// Watches source files and triggers WASM builds.
642///
643/// # Arguments
644///
645/// - `Arc<AppState>` - The shared application state.
646///
647/// # Returns
648///
649/// - `Result<(), EuvError>` - Indicates success or failure of the file watcher.
650pub(crate) async fn watch_and_build(state: Arc<AppState>) -> Result<(), EuvError> {
651    let crate_path: PathBuf = state.get_args().get_crate_path().clone();
652    let src_path: PathBuf = crate_path.join(SRC_DIR_NAME);
653    let gitignore: Gitignore = build_gitignore(&crate_path).await;
654    let (tx, mut rx): (Sender<Event>, Receiver<Event>) = channel(32);
655    let mut watcher: RecommendedWatcher = RecommendedWatcher::new(
656        move |result: Result<Event, notify::Error>| {
657            if let Ok(event) = result {
658                let _: Result<(), tokio::sync::mpsc::error::SendError<Event>> =
659                    tx.blocking_send(event);
660            }
661        },
662        Config::default(),
663    )?;
664    watcher.watch(&src_path, RecursiveMode::Recursive)?;
665    log::info!("Watching {} for changes...", src_path.display());
666    let mut debounce: Interval = interval(Duration::from_millis(500));
667    debounce.tick().await;
668    while let Some(event) = rx.recv().await {
669        let filtered_paths: Vec<String> = event
670            .paths
671            .iter()
672            .filter(|path: &&PathBuf| !gitignore.matched(*path, path.is_dir()).is_ignore())
673            .map(|path: &PathBuf| path.display().to_string())
674            .collect();
675        if filtered_paths.is_empty() {
676            continue;
677        }
678        log::warn!("File change detected: {}", filtered_paths.join(", "));
679        debounce.reset();
680        sleep(Duration::from_millis(300)).await;
681        let mut building: RwLockWriteGuard<bool> = state.get_is_building().write().await;
682        if *building {
683            continue;
684        }
685        *building = true;
686        drop(building);
687        let state_for_build: Arc<AppState> = Arc::clone(&state);
688        spawn(async move {
689            let args: ModeArgs = state_for_build.get_args().clone();
690            let reload_tx: broadcast::Sender<ReloadEvent> = state_for_build.get_reload_tx().clone();
691            match run_build_pipeline(&args, Some(&reload_tx)).await {
692                Ok(html) => {
693                    let mut content: RwLockWriteGuard<String> =
694                        state_for_build.get_html_content().write().await;
695                    *content = html;
696                }
697                Err(error) => {
698                    log::error!("Build pipeline error: {error}");
699                }
700            }
701            let mut building: RwLockWriteGuard<bool> =
702                state_for_build.get_is_building().write().await;
703            *building = false;
704        });
705    }
706    Ok(())
707}
708
709/// Runs wasm-pack build for the target crate.
710///
711/// All arguments in `args.wasm_pack_args` are transparently forwarded
712/// to `wasm-pack build`. If `--out-dir` is not specified by the user,
713/// `--out-dir {www_dir}/pkg` is automatically injected so that build artifacts
714/// are placed inside the www directory served by the development server.
715///
716/// # Arguments
717///
718/// - `&ModeArgs` - The CLI arguments containing crate_path, www_dir, and wasm_pack_args.
719///
720/// # Returns
721///
722/// - `Result<(), EuvError>` - Indicates success or failure of the wasm-pack build.
723pub async fn build_wasm(args: &ModeArgs) -> Result<(), EuvError> {
724    let build_mode: BuildMode = resolve_build_mode(args);
725    let build_mode_flag: &str = build_mode_to_flag(build_mode);
726    let filtered_args: Vec<String> = filter_euv_args(args.get_wasm_pack_args());
727    let has_existing_build_mode: bool = has_build_mode_flag(&filtered_args);
728    let default_out_dir: String = format!("{}/{PKG_DIR_NAME}", args.get_www_dir());
729    let mut command: Command = Command::new(WASM_PACK_COMMAND);
730    command.arg(WASM_PACK_BUILD_SUBCOMMAND);
731    if !has_existing_build_mode {
732        command.arg(build_mode_flag);
733    }
734    command
735        .args(&filtered_args)
736        .env(RUST_MIN_STACK_ENV, RUST_MIN_STACK_VALUE);
737    let has_out_dir: bool = extract_out_dir(&filtered_args).is_some();
738    if !has_out_dir {
739        command.arg(OUT_DIR_ARG).arg(&default_out_dir);
740    }
741    let has_target: bool = filtered_args
742        .iter()
743        .any(|arg: &String| arg == TARGET_ARG || arg.starts_with(&format!("{TARGET_ARG}=")));
744    if !has_target {
745        command.arg(TARGET_ARG).arg(TARGET_WEB);
746    }
747    command.current_dir(args.get_crate_path());
748    command.stdout(Stdio::piped()).stderr(Stdio::piped());
749    let display_args: Vec<String> = (if has_existing_build_mode {
750        filtered_args.to_vec()
751    } else {
752        once(build_mode_flag.to_string())
753            .chain(filtered_args.iter().cloned())
754            .collect::<Vec<String>>()
755    })
756    .into_iter()
757    .chain(if has_out_dir {
758        Vec::new()
759    } else {
760        vec![OUT_DIR_ARG.to_string(), default_out_dir.clone()]
761    })
762    .chain(if has_target {
763        Vec::new()
764    } else {
765        vec![TARGET_ARG.to_string(), TARGET_WEB.to_string()]
766    })
767    .collect();
768    let out_dir_absolute: PathBuf = resolve_out_dir(args);
769    create_dir_all(&out_dir_absolute)
770        .await
771        .map_err(|error: io::Error| EuvError::IoPath {
772            message: String::from("Failed to create output directory"),
773            path: out_dir_absolute.clone(),
774            error,
775        })?;
776    log::info!(
777        "Running: {WASM_PACK_COMMAND} {WASM_PACK_BUILD_SUBCOMMAND} {} ...",
778        display_args.join(" ")
779    );
780    let output: Output = command
781        .output()
782        .await
783        .map_err(|error: io::Error| EuvError::Io {
784            message: String::from("Failed to execute wasm-pack"),
785            error,
786        })?;
787    let stdout: String = String::from_utf8_lossy(&output.stdout).to_string();
788    let stderr: String = String::from_utf8_lossy(&output.stderr).to_string();
789    if args.get_no_gitignore() {
790        let gitignore_path: PathBuf = out_dir_absolute.join(GITIGNORE_FILE_NAME);
791        if gitignore_path.exists()
792            && let Err(error) = remove_file(&gitignore_path).await
793        {
794            log::warn!("Failed to remove '{}': {error}", gitignore_path.display());
795        }
796    }
797    for line in stdout.lines().filter(|line: &&str| !line.is_empty()) {
798        log::info!("{line}");
799    }
800    if output.status.success() {
801        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
802            log::info!("{line}");
803        }
804    } else {
805        for line in stderr.lines().filter(|line: &&str| !line.is_empty()) {
806            log::error!("{line}");
807        }
808        return Err(EuvError::Message(String::from("wasm-pack build failed")));
809    }
810    Ok(())
811}
812
813/// Prints the startup banner and command information.
814///
815/// # Arguments
816///
817/// - `Action` - The action to perform (run or build).
818pub fn print_banner(action: Action) {
819    let version: &str = env!("CARGO_PKG_VERSION");
820    if version.is_empty() {
821        log::warn!("Failed to parse version from root Cargo.toml");
822    } else {
823        log::info!("euv v{version}");
824    }
825    let action_name: &str = match action {
826        Action::Run => ACTION_RUN,
827        Action::Build => ACTION_BUILD,
828    };
829    log::info!("Mode: {action_name}");
830    log::info!(
831        "Use .gitignore to filter file change events; pass --no-gitignore to remove .gitignore from output"
832    );
833}
834
835/// Enumerates all network interface IP addresses and prints each server URL
836/// along with its corresponding QR code to the console.
837///
838/// Includes both loopback (127.0.0.1) and all private/public IPv4 addresses
839/// bound to the host's network interfaces. Each address produces one URL line
840/// followed by a Unicode QR code rendered with half-block characters,
841/// where every line carries the standard log prefix (timestamp + level).
842///
843/// # Arguments
844///
845/// - `&ServerUrlConfig` - The server URL configuration.
846pub(crate) fn print_server_urls(config: &ServerUrlConfig) {
847    let port: u16 = config.get_port();
848    let route_prefix: &str = config.get_route_prefix();
849    let index_html_file_name: &str = config.get_index_html_file_name();
850    let mut addresses: Vec<IpAddr> = Vec::new();
851    match if_addrs::get_if_addrs() {
852        Ok(interfaces) => {
853            for interface in interfaces {
854                let ip: IpAddr = interface.addr.ip();
855                if !addresses.contains(&ip) {
856                    addresses.push(ip);
857                }
858            }
859        }
860        Err(error) => {
861            log::warn!("Failed to enumerate network interfaces: {error}");
862        }
863    }
864    if addresses.is_empty() {
865        addresses.push(IpAddr::V4(Ipv4Addr::LOCALHOST));
866    }
867    for ip in addresses {
868        let host: String = match ip {
869            IpAddr::V6(_) => format!("[{ip}]"),
870            IpAddr::V4(_) => format!("{ip}"),
871        };
872        let url: String =
873            format!("{HTTP_SCHEME}://{host}:{port}/{route_prefix}/{index_html_file_name}");
874        log::info!("Server: {url}");
875        match QrCode::new(url.as_str()) {
876            Ok(code) => {
877                let string: String = code.render::<Dense1x2>().quiet_zone(false).build();
878                for line in string.lines() {
879                    log::info!("{line}");
880                }
881            }
882            Err(error) => {
883                log::warn!("Failed to generate QR code: {error}");
884            }
885        }
886    }
887}
888
889/// Reads the wasm-bindgen JS bridge produced by `wasm-pack build
890/// --target web` and returns a synchronous IIFE that inlines the bridge
891/// and its snippet modules, fetches the wasm module from `wasm_url`,
892/// then calls `main()` once the instance is ready.
893///
894/// The bridge's own top-level `import` lines (the tiny snippet helpers
895/// under `./snippets/`) are stripped and their bodies inlined, so the
896/// result is self-contained and the browser makes zero extra requests
897/// for the bridge file.
898/// # Arguments
899///
900/// - `&Path` - Directory holding the wasm-pack output.
901/// - `&str` - File name of the generated JS bridge inside it.
902/// - `&str` - URL the page will fetch the `.wasm` module from.
903///
904/// # Returns
905///
906/// - `Result<String, EuvError>` - The IIFE source, or an error if the
907///   bridge or one of its snippet imports cannot be read.
908pub(crate) async fn build_inline_bridge(
909    pkg_dir: &Path,
910    js_name: &str,
911    wasm_url: &str,
912) -> Result<String, EuvError> {
913    let js_path: PathBuf = pkg_dir.join(js_name);
914    let bridge_source: String =
915        read_to_string(&js_path)
916            .await
917            .map_err(|error: io::Error| EuvError::IoPath {
918                message: String::from("Failed to read wasm-bindgen JS bridge"),
919                path: js_path.clone(),
920                error,
921            })?;
922    let snippet_bodies: String = collect_snippet_bodies(&bridge_source, pkg_dir).await?;
923    let stripped: String = strip_module_imports(&bridge_source);
924    let js_only: String = strip_trailing_module_exports(&stripped);
925    let wasm_url_json: String = serde_json_wasm_url(wasm_url);
926    Ok(format!(
927        "(function() {{\n\
928         {snippet_bodies}\n\
929         {js_only}\n\
930         var __euv_wasm_url = {wasm_url_json};\n\
931         if (typeof __wbg_init === 'function') {{\n\
932         var __euv_base = (document.querySelector('base[href]') && document.baseURI) || location.href;\n         __wbg_init(new URL(__euv_wasm_url, __euv_base).toString()).then(function() {{\n\
933         if (typeof main === 'function') main();\n\
934         }}).catch(function(e) {{ console.error('[euv] inline bridge init failed:', e); }});\n\
935         }} else if (typeof initSync === 'function') {{\n\
936         initSync();\n\
937         if (typeof main === 'function') main();\n\
938         }} else {{\n\
939         console.error('[euv] inline bridge: no __wbg_init or initSync exported');\n\
940         }}\n\
941         }})();\n"
942    ))
943}
944
945/// Returns the inline `<script>` body to use when JS bridge inlining is
946/// disabled, restoring the classic module import as a fallback during
947/// wasm-pack output transitions.
948///
949/// # Arguments
950///
951/// - `&str` - The module import path the browser should resolve.
952///
953/// # Returns
954///
955/// - `String` - The `<script>` body, ready to be embedded in the page.
956pub(crate) fn build_module_fallback_bridge(import_path: &str) -> String {
957    format!(
958        "    import init, {{ main }} from '{import_path}';\n    \
959         await init();\n    \
960         main();\n"
961    )
962}
963
964/// Returns `true` when the inline bridge should be skipped.
965///
966/// # Returns
967///
968/// - `bool` - `true` if the env var is set to a non-empty value.
969pub(crate) fn inline_bridge_disabled() -> bool {
970    matches!(var(EUV_NO_INLINE_BRIDGE_ENV), Ok(value) if !value.is_empty())
971}
972
973/// Collects the source of every module the bridge imports, inlined in
974/// dependency order, and materialises each namespace import as a `var`
975/// object so the bridge body keeps working without the module graph.
976///
977/// # Arguments
978///
979/// - `&str` - The bridge source whose `import` lines drive the scan.
980/// - `&Path` - Directory holding the wasm-pack output, used to resolve
981///   each relative specifier.
982///
983/// # Returns
984///
985/// - `Result<String, EuvError>` - The concatenated snippet bodies, or an
986///   error if a specifier escapes the package directory.
987async fn collect_snippet_bodies(bridge_source: &str, pkg_dir: &Path) -> Result<String, EuvError> {
988    let mut bodies: String = String::new();
989    let mut namespace_imports: Vec<NamespaceImport> = Vec::new();
990    for line in bridge_source.lines() {
991        let trimmed: &str = line.trim();
992        if !trimmed.starts_with("import ") {
993            continue;
994        }
995        let rest: &str = match trimmed.strip_prefix("import ") {
996            Some(value) => value,
997            None => continue,
998        };
999        let spec: &str = match extract_import_spec(rest) {
1000            Some(value) => value,
1001            None => continue,
1002        };
1003        if is_namespace_import(rest)
1004            && let Some(alias) = extract_namespace_alias(rest)
1005        {
1006            namespace_imports.push(NamespaceImport {
1007                alias: alias.to_string(),
1008                spec: spec.to_string(),
1009            });
1010        }
1011        let body: Option<String> = read_snippet_module(pkg_dir, spec).await?;
1012        if let Some(body) = body {
1013            if !bodies.is_empty() {
1014                bodies.push('\n');
1015            }
1016            bodies.push_str(&body);
1017        }
1018    }
1019    for ns in &namespace_imports {
1020        let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, &ns.spec)?;
1021        let exports: Vec<String> = if snippet_path.exists() {
1022            let raw: String = read_to_string(&snippet_path)
1023                .await
1024                .map_err(|error: io::Error| EuvError::IoPath {
1025                    message: String::from(
1026                        "Failed to read wasm-pack snippet module for namespace export scan",
1027                    ),
1028                    path: snippet_path.clone(),
1029                    error,
1030                })?;
1031            extract_exported_function_names(&raw)
1032        } else {
1033            Vec::new()
1034        };
1035        if !bodies.is_empty() {
1036            bodies.push('\n');
1037        }
1038        bodies.push_str(&format!("var {} = {{", ns.alias));
1039        for (i, name) in exports.iter().enumerate() {
1040            if i > 0 {
1041                bodies.push(',');
1042            }
1043            bodies.push_str(name);
1044            bodies.push(':');
1045            bodies.push_str(name);
1046        }
1047        bodies.push_str("};\n");
1048    }
1049    Ok(bodies)
1050}
1051
1052/// Scans a snippet module for `export function NAME(` declarations and
1053/// returns their names. The IIFE relies on function declarations being
1054/// hoisted, so these names resolve to the same bindings that
1055/// wasm-bindgen's `__wbg_get_imports` expects.
1056///
1057/// # Arguments
1058///
1059/// - `&str` - The snippet module source to scan.
1060///
1061/// # Returns
1062///
1063/// - `Vec<String>` - Every exported function name, in source order.
1064pub fn extract_exported_function_names(source: &str) -> Vec<String> {
1065    let mut names: Vec<String> = Vec::new();
1066    for line in source.lines() {
1067        let trimmed: &str = line.trim();
1068        if let Some(rest) = trimmed.strip_prefix("export function ") {
1069            let name_end: usize =
1070                match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
1071                    Some(value) => value,
1072                    None => rest.len(),
1073                };
1074            let name: &str = &rest[..name_end];
1075            if !name.is_empty() {
1076                names.push(name.to_string());
1077            }
1078        }
1079    }
1080    names
1081}
1082
1083/// Returns `true` when the clause is a namespace import (`import * as X`).
1084///
1085/// # Arguments
1086///
1087/// - `&str` - The import clause, without its leading `import` keyword.
1088///
1089/// # Returns
1090///
1091/// - `bool` - `true` for `* as <alias>` clauses.
1092pub fn is_namespace_import(rest: &str) -> bool {
1093    rest.trim_start().starts_with('*')
1094}
1095
1096/// Extracts the local binding a namespace import introduces.
1097///
1098/// # Arguments
1099///
1100/// - `&str` - The import clause, without its leading `import` keyword.
1101///
1102/// # Returns
1103///
1104/// - `Option<&str>` - The alias, or `None` if the clause is not a
1105///   namespace import or carries no name.
1106pub fn extract_namespace_alias(rest: &str) -> Option<&str> {
1107    let rest: &str = rest.trim_end_matches(';').trim();
1108    if !rest.starts_with('*') {
1109        return None;
1110    }
1111    let rest: &str = rest.strip_prefix('*')?.trim();
1112    let rest: &str = rest.strip_prefix("as")?.trim();
1113    let end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_' && c != '$') {
1114        Some(value) => value,
1115        None => rest.len(),
1116    };
1117    let alias: &str = &rest[..end];
1118    if alias.is_empty() { None } else { Some(alias) }
1119}
1120
1121/// Extracts the relative specifier an import clause resolves against.
1122///
1123/// Bare specifiers are rejected: only `./` and `../` targets are inlined,
1124/// because anything else would drag in a module this code does not
1125/// control.
1126///
1127/// # Arguments
1128///
1129/// - `&str` - The import clause, without its leading `import` keyword.
1130///
1131/// # Returns
1132///
1133/// - `Option<&str>` - The specifier with its quotes stripped, or `None`
1134///   when the clause has no `from` or names a non-relative module.
1135pub fn extract_import_spec(rest: &str) -> Option<&str> {
1136    let rest: &str = rest.trim_end_matches(';').trim();
1137    let from_idx: usize = rest.find(" from ")?;
1138    let spec_part: &str = rest[from_idx + " from ".len()..].trim();
1139    let spec: &str = spec_part
1140        .trim_start_matches('\'')
1141        .trim_start_matches('"')
1142        .trim_end_matches('\'')
1143        .trim_end_matches('"');
1144    if spec.starts_with("./") || spec.starts_with("../") {
1145        Some(spec)
1146    } else {
1147        None
1148    }
1149}
1150
1151/// Reads one snippet module and returns its body with the `export` keyword
1152/// stripped, so it can be concatenated into the IIFE as a plain
1153/// declaration.
1154///
1155/// # Arguments
1156///
1157/// - `&Path` - Directory holding the wasm-pack output.
1158/// - `&str` - Relative specifier of the snippet to read.
1159///
1160/// # Returns
1161///
1162/// - `Result<Option<String>, EuvError>` - The stripped body, `None` if the
1163///   file does not exist, or an error if it cannot be read or decoded.
1164async fn read_snippet_module(pkg_dir: &Path, spec: &str) -> Result<Option<String>, EuvError> {
1165    let snippet_path: PathBuf = resolve_snippet_path(pkg_dir, spec)?;
1166    if !snippet_path.exists() {
1167        return Ok(None);
1168    }
1169    let bytes: Vec<u8> =
1170        read(&snippet_path)
1171            .await
1172            .map_err(|error: io::Error| EuvError::IoPath {
1173                message: String::from("Failed to read wasm-pack snippet module"),
1174                path: snippet_path.clone(),
1175                error,
1176            })?;
1177    let raw: String = String::from_utf8(bytes).map_err(|error: FromUtf8Error| EuvError::Utf8 {
1178        message: String::from("Snippet module is not valid UTF-8"),
1179        error,
1180    })?;
1181    Ok(Some(strip_snippet_export(&raw)))
1182}
1183
1184/// Resolves a relative specifier to a path inside `pkg_dir`.
1185///
1186/// # Arguments
1187///
1188/// - `&Path` - Directory holding the wasm-pack output.
1189/// - `&str` - Relative specifier taken from the bridge's import clause.
1190///
1191/// # Returns
1192///
1193/// - `Result<PathBuf, EuvError>` - The resolved path, or an error if the
1194///   specifier climbs above `pkg_dir`.
1195fn resolve_snippet_path(pkg_dir: &Path, spec: &str) -> Result<PathBuf, EuvError> {
1196    let base: &str = spec.trim_start_matches('.').trim_start_matches('/');
1197    let mut path: PathBuf = pkg_dir.to_path_buf();
1198    for segment in base.split('/') {
1199        if segment.is_empty() || segment == "." {
1200            continue;
1201        }
1202        if segment == ".." {
1203            return Err(EuvError::Message(String::from(
1204                "Snippet import spec escapes pkg directory",
1205            )));
1206        }
1207        path.push(segment);
1208    }
1209    Ok(path)
1210}
1211
1212/// Rewrites a snippet module's source into bare declarations by removing
1213/// the `export ` prefix from every line that carries one.
1214///
1215/// # Arguments
1216///
1217/// - `&str` - The snippet module source.
1218///
1219/// # Returns
1220///
1221/// - `String` - The same source with the `export ` keywords removed.
1222fn strip_snippet_export(raw: &str) -> String {
1223    let mut out: String = String::with_capacity(raw.len());
1224    for line in raw.lines() {
1225        let trimmed: &str = line.trim();
1226        if trimmed.starts_with("export ") {
1227            if let Some(rest) = trimmed.strip_prefix("export ") {
1228                out.push_str(rest);
1229                out.push('\n');
1230            }
1231        } else {
1232            out.push_str(line);
1233            out.push('\n');
1234        }
1235    }
1236    out
1237}
1238
1239/// Removes the bridge's own `import` lines and any line referencing
1240/// `import.meta.url`, since the inlined IIFE has neither.
1241///
1242/// # Arguments
1243///
1244/// - `&str` - The bridge source.
1245///
1246/// # Returns
1247///
1248/// - `String` - The source with those lines dropped.
1249fn strip_module_imports(source: &str) -> String {
1250    let mut out: String = String::with_capacity(source.len());
1251    for line in source.lines() {
1252        let trimmed: &str = line.trim();
1253        if trimmed.starts_with("import ") {
1254            continue;
1255        }
1256        if trimmed.contains("import.meta.url") {
1257            continue;
1258        }
1259        out.push_str(line);
1260        out.push('\n');
1261    }
1262    out
1263}
1264
1265/// Removes the bridge's trailing `export { ... }` and `export function`
1266/// lines, turning each exported alias into the assignment that keeps the
1267/// local function reachable under its exported name.
1268///
1269/// # Arguments
1270///
1271/// - `&str` - The bridge source, after [`strip_module_imports`].
1272///
1273/// # Returns
1274///
1275/// - `String` - The source with its module-level export syntax removed.
1276fn strip_trailing_module_exports(source: &str) -> String {
1277    let mut out: String = String::with_capacity(source.len());
1278    for line in source.lines() {
1279        let trimmed: &str = line.trim();
1280        if trimmed.starts_with("export ") && trimmed.contains('{') && trimmed.contains('}') {
1281            let inner: &str = &trimmed["export ".len()..];
1282            let stripped: &str = match inner.strip_prefix('{') {
1283                Some(value) => value,
1284                None => {
1285                    out.push_str(line);
1286                    out.push('\n');
1287                    continue;
1288                }
1289            };
1290            let close: usize = match stripped.rfind('}') {
1291                Some(value) => value,
1292                None => {
1293                    out.push_str(line);
1294                    out.push('\n');
1295                    continue;
1296                }
1297            };
1298            let names_block: &str = &stripped[..close];
1299            let inlined: String = inline_export_aliases(names_block.trim());
1300            if !inlined.is_empty() {
1301                out.push_str(&inlined);
1302                out.push('\n');
1303            }
1304            continue;
1305        }
1306        if trimmed.starts_with("export function ") {
1307            let rest: &str = match trimmed.strip_prefix("export function ") {
1308                Some(value) => value,
1309                None => {
1310                    out.push_str(line);
1311                    out.push('\n');
1312                    continue;
1313                }
1314            };
1315            let name_end: usize = match rest.find(|c: char| !c.is_alphanumeric() && c != '_') {
1316                Some(value) => value,
1317                None => rest.len(),
1318            };
1319            let name: &str = &rest[..name_end];
1320            if !name.is_empty() {
1321                out.push_str("function ");
1322                out.push_str(name);
1323                out.push_str(&rest[name_end..]);
1324                out.push('\n');
1325                continue;
1326            }
1327        }
1328        out.push_str(line);
1329        out.push('\n');
1330    }
1331    out
1332}
1333
1334/// Converts the body of an `export { a, b as c }` clause into the
1335/// assignments that keep each local function reachable under its
1336/// exported name. Exports of `wasm` are dropped: the IIFE is a plain
1337/// `<script>`, so there is no `wasm` binding in scope.
1338///
1339/// # Arguments
1340///
1341/// - `&str` - The comma-separated body between the export braces.
1342///
1343/// # Returns
1344///
1345/// - `String` - The assignments, one per line, or empty when every alias
1346///   is a no-op.
1347fn inline_export_aliases(block: &str) -> String {
1348    let mut out: String = String::new();
1349    for entry in block.split(',') {
1350        let entry: &str = entry.trim();
1351        if entry.is_empty() {
1352            continue;
1353        }
1354        if let Some((original, exported)) = entry.split_once(" as ") {
1355            let original: &str = original.trim();
1356            let exported: &str = exported.trim();
1357            if original == exported || exported == "default" || original == "wasm" {
1358                continue;
1359            }
1360            out.push_str(exported);
1361            out.push_str(" = ");
1362            out.push_str(original);
1363            out.push_str(";\n");
1364        }
1365    }
1366    out
1367}
1368
1369/// Encodes a URL as a JSON string literal, including the surrounding
1370/// quotes.
1371///
1372/// Hand-rolled rather than `serde_json::to_string` because the value is
1373/// interpolated into a template that is already assembling JavaScript,
1374/// and the only characters that can appear here are URL characters.
1375///
1376/// # Arguments
1377///
1378/// - `&str` - The URL to encode.
1379///
1380/// # Returns
1381///
1382/// - `String` - A quoted, escaped JSON string.
1383fn serde_json_wasm_url(url: &str) -> String {
1384    let mut out: String = String::with_capacity(url.len() + 2);
1385    out.push('"');
1386    for ch in url.chars() {
1387        match ch {
1388            '"' => out.push_str("\\\""),
1389            '\\' => out.push_str("\\\\"),
1390            '\n' => out.push_str("\\n"),
1391            '\r' => out.push_str("\\r"),
1392            '\t' => out.push_str("\\t"),
1393            '\u{08}' => out.push_str("\\b"),
1394            '\u{0C}' => out.push_str("\\f"),
1395            c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
1396            c => out.push(c),
1397        }
1398    }
1399    out.push('"');
1400    out
1401}