fix(bundle): permits use of JS 'import * as' + ensure no duplicated importation
example: here it fixes issue with `static/js/features/library/recent.js` and `static/js/features/library/favorites.js`
ithat are both imporing:
```javascript
import * as pathTooltip from '../pathTooltip.js'
```
This commit is contained in:
@@ -284,12 +284,23 @@ fn build_js_module_bundle(entry_scripts: &[String], static_dir: &Path) -> String
|
|||||||
collect_module_deps(&path, &mut order, &mut seen);
|
collect_module_deps(&path, &mut order, &mut seen);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
println!(
|
||||||
|
"cargo:warning=bundle: {} files in dependency order:",
|
||||||
|
order.len()
|
||||||
|
);
|
||||||
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
let mut bundle = String::with_capacity(2 * 1024 * 1024);
|
||||||
bundle.push_str("(function(){\n\"use strict\";\n");
|
bundle.push_str("(function(){\n\"use strict\";\n");
|
||||||
for file in &order {
|
let mut declared_namespaces = std::collections::HashSet::new();
|
||||||
|
for (i, file) in order.iter().enumerate() {
|
||||||
|
println!(
|
||||||
|
"cargo:warning=bundle [{:>3}/{}] {}",
|
||||||
|
i + 1,
|
||||||
|
order.len(),
|
||||||
|
file.display()
|
||||||
|
);
|
||||||
match fs::read_to_string(file) {
|
match fs::read_to_string(file) {
|
||||||
Ok(src) => {
|
Ok(src) => {
|
||||||
bundle.push_str(&strip_esm_syntax(&src));
|
bundle.push_str(&strip_esm_syntax(&src, file, &mut declared_namespaces));
|
||||||
bundle.push('\n');
|
bundle.push('\n');
|
||||||
}
|
}
|
||||||
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
|
Err(e) => eprintln!("cargo:warning=bundle: cannot read {}: {e}", file.display()),
|
||||||
@@ -326,7 +337,11 @@ fn collect_module_deps(
|
|||||||
// Skip vendor bundles: they may use top-level await or other ESM
|
// Skip vendor bundles: they may use top-level await or other ESM
|
||||||
// patterns that are incompatible with IIFE wrapping. They must be
|
// patterns that are incompatible with IIFE wrapping. They must be
|
||||||
// loaded via dynamic import() at runtime instead.
|
// loaded via dynamic import() at runtime instead.
|
||||||
if !target.components().any(|c| c.as_os_str() == "vendors") {
|
// Skip also workers path
|
||||||
|
if !target
|
||||||
|
.components()
|
||||||
|
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
|
||||||
|
{
|
||||||
collect_module_deps(&target, order, seen);
|
collect_module_deps(&target, order, seen);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -381,12 +396,70 @@ fn extract_from_clause(s: &str) -> Option<String> {
|
|||||||
Some(rest[1..end].to_string())
|
Some(rest[1..end].to_string())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Extract all names that a JS module source exports.
|
||||||
|
///
|
||||||
|
/// Handles:
|
||||||
|
/// - `export { X, Y };` and `export { X as Z };`
|
||||||
|
/// - `export function f`, `export async function f`, `export class C`
|
||||||
|
/// - `export const X`, `export let X`, `export var X`
|
||||||
|
///
|
||||||
|
/// Does NOT follow `export { X } from '...'` re-exports.
|
||||||
|
fn extract_exported_names(source: &str) -> Vec<String> {
|
||||||
|
let mut names = Vec::new();
|
||||||
|
|
||||||
|
for line in source.lines() {
|
||||||
|
let t = line.trim();
|
||||||
|
|
||||||
|
// export { X, Y } — skip re-exports from other modules
|
||||||
|
if (t.starts_with("export {") || t.starts_with("export{")) && !t.contains(" from ") {
|
||||||
|
if let (Some(start), Some(end)) = (t.find('{'), t.find('}')) {
|
||||||
|
for binding in t[start + 1..end].split(',') {
|
||||||
|
let b = binding.trim();
|
||||||
|
let exported = if let Some(pos) = b.find(" as ") {
|
||||||
|
b[pos + 4..].trim()
|
||||||
|
} else {
|
||||||
|
b
|
||||||
|
};
|
||||||
|
if !exported.is_empty() {
|
||||||
|
names.push(exported.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const DECL_PREFIXES: &[&str] = &[
|
||||||
|
"export async function ",
|
||||||
|
"export function ",
|
||||||
|
"export class ",
|
||||||
|
"export const ",
|
||||||
|
"export let ",
|
||||||
|
"export var ",
|
||||||
|
];
|
||||||
|
for prefix in DECL_PREFIXES {
|
||||||
|
if let Some(rest) = t.strip_prefix(prefix) {
|
||||||
|
let name: String = rest
|
||||||
|
.chars()
|
||||||
|
.take_while(|c| c.is_alphanumeric() || *c == '_' || *c == '$')
|
||||||
|
.collect();
|
||||||
|
if !name.is_empty() {
|
||||||
|
names.push(name);
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
names
|
||||||
|
}
|
||||||
|
|
||||||
/// Strip ES-module syntax from a single file so it can be inlined into an IIFE.
|
/// Strip ES-module syntax from a single file so it can be inlined into an IIFE.
|
||||||
///
|
///
|
||||||
/// | Input | Output |
|
/// | Input | Output |
|
||||||
/// |------------------------------------------|------------------------------|
|
/// |------------------------------------------|---------------------------------------------|
|
||||||
/// | `import { X } from './y.js';` | *(empty line)* |
|
/// | `import { X } from './y.js';` | *(empty line)* |
|
||||||
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
|
/// | `import { X as Y } from './y.js';` | `const Y = X;` |
|
||||||
|
/// | `import * as ns from './y.js';` | `const ns = { export1, export2, … };` |
|
||||||
/// | `export { X, Y };` | *(empty line)* |
|
/// | `export { X, Y };` | *(empty line)* |
|
||||||
/// | `export { X } from './y.js';` | *(empty line)* |
|
/// | `export { X } from './y.js';` | *(empty line)* |
|
||||||
/// | `export const X = …` | `const X = …` |
|
/// | `export const X = …` | `const X = …` |
|
||||||
@@ -394,7 +467,11 @@ fn extract_from_clause(s: &str) -> Option<String> {
|
|||||||
/// | `export async function f() {…}` | `async function f() {…}` |
|
/// | `export async function f() {…}` | `async function f() {…}` |
|
||||||
/// | `export class C {…}` | `class C {…}` |
|
/// | `export class C {…}` | `class C {…}` |
|
||||||
/// | `export default expr;` | `const _default = expr;` |
|
/// | `export default expr;` | `const _default = expr;` |
|
||||||
fn strip_esm_syntax(source: &str) -> String {
|
fn strip_esm_syntax(
|
||||||
|
source: &str,
|
||||||
|
file: &Path,
|
||||||
|
declared_namespaces: &mut std::collections::HashSet<String>,
|
||||||
|
) -> String {
|
||||||
let mut out = String::with_capacity(source.len());
|
let mut out = String::with_capacity(source.len());
|
||||||
// True while we are inside a multi-line import/export-list that has not yet
|
// True while we are inside a multi-line import/export-list that has not yet
|
||||||
// seen its terminating `;`.
|
// seen its terminating `;`.
|
||||||
@@ -412,6 +489,66 @@ fn strip_esm_syntax(source: &str) -> String {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── import * as ns from './path.js' ───────────────────────────────────
|
||||||
|
// Build a synthetic namespace object from the module's exports so that
|
||||||
|
// `ns.foo()` calls resolve correctly inside the IIFE scope.
|
||||||
|
// If multiple files import the same namespace name, only the first
|
||||||
|
// declaration is emitted — subsequent ones become empty lines to avoid
|
||||||
|
// `SyntaxError: Identifier already declared`.
|
||||||
|
if t.starts_with("import * as ") {
|
||||||
|
let stmt = (|| -> Option<String> {
|
||||||
|
// Extract the namespace identifier
|
||||||
|
let after_as = t.strip_prefix("import * as ")?;
|
||||||
|
let name_end = after_as.find(' ')?;
|
||||||
|
let ns_name = &after_as[..name_end];
|
||||||
|
|
||||||
|
// Already declared earlier in the bundle — skip re-declaration.
|
||||||
|
if declared_namespaces.contains(ns_name) {
|
||||||
|
return Some(String::new());
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the module path from the `from '…'` clause
|
||||||
|
let module_path = extract_from_clause(t)?;
|
||||||
|
if !module_path.starts_with('.') {
|
||||||
|
return None; // bare specifier — not bundled
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip vendor/worker bundles (dynamically loaded at runtime)
|
||||||
|
let base = file.parent().unwrap_or(Path::new("."));
|
||||||
|
let target = base.join(&module_path);
|
||||||
|
if target
|
||||||
|
.components()
|
||||||
|
.any(|c| c.as_os_str() == "vendors" || c.as_os_str() == "workers")
|
||||||
|
{
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let module_src = fs::read_to_string(&target).ok()?;
|
||||||
|
let exports = extract_exported_names(&module_src);
|
||||||
|
if exports.is_empty() {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
declared_namespaces.insert(ns_name.to_string());
|
||||||
|
let indent = &line[..line.len() - line.trim_start().len()];
|
||||||
|
Some(format!(
|
||||||
|
"{}const {} = {{ {} }};",
|
||||||
|
indent,
|
||||||
|
ns_name,
|
||||||
|
exports.join(", ")
|
||||||
|
))
|
||||||
|
})();
|
||||||
|
|
||||||
|
match stmt {
|
||||||
|
Some(s) => out.push_str(&s),
|
||||||
|
None => {
|
||||||
|
println!("cargo:warning=bundle: could not resolve namespace import: {t}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.push('\n');
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
// ── import … ──────────────────────────────────────────────────────────
|
// ── import … ──────────────────────────────────────────────────────────
|
||||||
// Emit `const Y = X;` for any `import { X as Y }` aliases so that code
|
// Emit `const Y = X;` for any `import { X as Y }` aliases so that code
|
||||||
// using the aliased name still resolves inside the IIFE scope.
|
// using the aliased name still resolves inside the IIFE scope.
|
||||||
@@ -600,6 +737,7 @@ fn minify_tree_js(dir: &Path) {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if let Ok(src) = fs::read_to_string(&p) {
|
if let Ok(src) = fs::read_to_string(&p) {
|
||||||
|
println!("cargo:warning=minify-js: {}", p.display());
|
||||||
let _ = fs::write(&p, js_minify_safe(&src));
|
let _ = fs::write(&p, js_minify_safe(&src));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user