Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
8ee57a8
feat(template-compiler): bind `@if (expr; as alias)` value at runtime…
lukekania May 18, 2026
b2e1bc6
chore: bump version to 0.10.9
lukekania May 18, 2026
d53b461
Merge pull request #176 from lukekania/feat/template-if-alias
lukekania May 18, 2026
68a6c85
feat(bundler): honor angular.json `externalDependencies` (#146)
lukekania May 18, 2026
a09ab0e
Merge pull request #174 from lukekania/feat/bundler-external-deps
lukekania May 18, 2026
b3654b6
feat(i18n): honor `localize: [...]` per-locale subset
lukekania May 18, 2026
23811fc
Merge pull request #180 from lukekania/feat/i18n-localize-subset
lukekania May 18, 2026
b2c5601
feat(dev-server): support `allowedHosts` (Codespaces / ngrok / *.loca…
lukekania May 18, 2026
f874be6
Merge pull request #173 from lukekania/feat/dev-server-allowed-hosts
lukekania May 18, 2026
800012a
feat(bundler): per-provider tree-shake for vendor chunks (#171)
lukekania May 18, 2026
05b30d0
chore: cargo fmt
lukekania May 18, 2026
46f03b5
Merge pull request #179 from lukekania/feat/bundler-per-provider-shake
lukekania May 18, 2026
f3781b2
feat(ngsw): per-locale ngsw.json manifests when --localize is set (#148)
lukekania May 20, 2026
c53f183
Merge pull request #182 from lukekania/feat/ngsw-per-locale-manifests
lukekania May 22, 2026
7fb4233
feat(dev-server): support `headers` custom HTTP response headers (#143)
lukekania May 22, 2026
93c8749
Merge pull request #183 from lukekania/feat/dev-server-custom-headers
lukekania Jun 8, 2026
46c77ec
feat(dev-server): support ssl/sslKey/sslCert for HTTPS dev (#142)
lukekania Jun 8, 2026
92afdd1
Merge pull request #184 from lukekania/feat/dev-server-ssl
lukekania Jun 8, 2026
44fd069
feat(dev-server): hmr config + global CSS hot-swap (#145)
lukekania Jun 8, 2026
f29bc30
feat(dev-server): /@ng/component endpoint + HMR runtime bus (#145)
lukekania Jun 8, 2026
95242a1
feat(hmr): component template & style hot module replacement (#145)
lukekania Jun 8, 2026
43940f7
Merge pull request #185 from lukekania/feat/hmr-config-css-swap
lukekania Jun 9, 2026
8aeb49f
Merge pull request #186 from lukekania/feat/hmr-component-endpoint
lukekania Jun 9, 2026
f4e31ca
Merge pull request #187 from lukekania/feat/hmr-compiler-codegen
lukekania Jun 9, 2026
3986b78
chore: bump version to 0.10.17 (#145 HMR)
lukekania Jun 9, 2026
fa1199a
feat(builder): accept and forward the dev-server `hmr` option (#145)
lukekania Jun 10, 2026
5059196
chore: bump version to 0.10.18 (#145 builder hmr option)
lukekania Jun 10, 2026
eadd4ff
Merge pull request #188 from lukekania/feat/builder-hmr-option
lukekania Jun 10, 2026
4859d0e
feat(bundler,compiler): close builder parity gaps for tree-shaking an…
lukekania Jul 15, 2026
c56b132
style: cargo fmt --all
lukekania Jul 15, 2026
676cc76
fix(template-compiler): satisfy clippy::question-mark in host_codegen
lukekania Jul 15, 2026
a8482ef
chore: bump version to 0.11.0
lukekania Jul 15, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
584 changes: 563 additions & 21 deletions Cargo.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ resolver = "2"
members = ["crates/cli", "crates/diagnostics", "crates/project-resolver", "crates/ts-transform", "crates/bundler", "crates/template-compiler", "crates/npm-resolver", "crates/linker", "crates/watch", "crates/dev-server"]

[workspace.package]
version = "0.10.8"
version = "0.11.0"
edition = "2021"
license = "MIT OR Apache-2.0"
authors = ["lukekania"]
Expand Down
7 changes: 6 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -116,11 +116,16 @@ When an `angular.json` is found, ngc-rs reads styles, assets, polyfills, and fil

### `ngc-rs serve`

Build the project, watch for source changes, and host `dist/` over HTTP with live reload — the `ng serve` equivalent for everyday Angular development:
Build the project, watch for source changes, and host `dist/` over HTTP (or HTTPS) with live reload — the `ng serve` equivalent for everyday Angular development:

```sh
ngc-rs serve --project tsconfig.app.json
ngc-rs serve --project tsconfig.app.json --host 0.0.0.0 --port 4300 --open

# HTTPS with an auto-generated self-signed certificate (browsers show the
# usual untrusted-certificate warning), or pass your own cert/key:
ngc-rs serve --project tsconfig.app.json --ssl
ngc-rs serve --project tsconfig.app.json --ssl --ssl-key dev.key --ssl-cert dev.crt
```

## Benchmark comparison
Expand Down
209 changes: 185 additions & 24 deletions crates/bundler/src/concat.rs

Large diffs are not rendered by default.

95 changes: 81 additions & 14 deletions crates/bundler/src/npm_wrap.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
//! })(__ns_abc123);
//! ```

use std::collections::{BTreeSet, HashMap};
use std::collections::{BTreeSet, HashMap, HashSet};
use std::path::Path;

use ngc_diagnostics::{NgcError, NgcResult};
Expand All @@ -39,15 +39,29 @@ pub struct NpmModuleInfo {
///
/// `resolve_import` is a closure that maps an import specifier to a namespace
/// variable name, or `None` if the import should be left as-is (truly external).
///
/// `unused_exports` carries the set of exported names that per-provider
/// shake decided no consumer reaches. When provided, both the declarations
/// (`export const X = ...`) and the matching `__exports.X = ...` bridge
/// lines are dropped, shrinking vendor chunks of large packages like
/// `@angular/core` whose `index.mjs` re-exports far more than any consumer
/// actually uses.
pub fn wrap_npm_module<F>(
js_code: &str,
file_name: &str,
namespace: &str,
unused_exports: Option<&HashSet<String>>,
resolve_import: F,
) -> NgcResult<NpmModuleInfo>
where
F: Fn(&str) -> Option<String>,
{
let is_unused = |name: &str| -> bool {
unused_exports
.map(|set| set.contains(name))
.unwrap_or(false)
};

// Strip sourcemap comments upfront to prevent them from interfering with
// the IIFE wrapping (they can eat export assignments on the same line).
let cleaned_code = strip_sourcemap_comments(js_code);
Expand Down Expand Up @@ -133,6 +147,9 @@ where
for spec in &export.specifiers {
let exported = spec.exported.name().to_string();
let local = spec.local.name().to_string();
if is_unused(&exported) {
continue;
}
// Don't add to exported_names — we handle the export inline
if let Some(ref ns) = target_ns {
replacements.push(format!("__exports.{exported} = {ns}.{local};"));
Expand All @@ -150,21 +167,39 @@ where
end: export.span.end,
replacement,
});
} else if export.declaration.is_some() {
// export const X = ...; → strip "export "
if let Some(decl) = &export.declaration {
collect_decl_names(decl, &mut exported_names);
} else if let Some(decl) = &export.declaration {
// export const X = ...; — if X is unused, drop the
// entire declaration so the body isn't pinned by
// its `__exports.X = X` line and any const
// initializer side-effect is also eliminated.
let mut decl_names = Vec::new();
collect_decl_names(decl, &mut decl_names);
let all_unused =
!decl_names.is_empty() && decl_names.iter().all(|n| is_unused(n));
if all_unused {
edits.push(TextEdit {
start: export.span.start,
end: export.span.end,
replacement: None,
});
} else {
for n in &decl_names {
exported_names.push(n.clone());
}
edits.push(TextEdit {
start: export.span.start,
end: export.span.start + 7, // "export "
replacement: None,
});
}
edits.push(TextEdit {
start: export.span.start,
end: export.span.start + 7, // "export "
replacement: None,
});
} else {
// export { X, Y }; or export { X as Y }; → collect names and remove
for spec in &export.specifiers {
let exported = spec.exported.name().to_string();
let local = spec.local.name().to_string();
if is_unused(&exported) {
continue;
}
if exported != local {
renamed_exports.insert(exported.clone(), local);
}
Expand Down Expand Up @@ -428,7 +463,7 @@ mod tests {
#[test]
fn test_wrap_simple_module() {
let code = "export function hello() { return 42; }\n";
let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap();
assert!(result.wrapped_code.contains("var __ns_test = {};"));
assert!(result.wrapped_code.contains("(function(__exports)"));
assert!(result.wrapped_code.contains("__exports.hello = hello;"));
Expand All @@ -447,7 +482,7 @@ mod tests {
None
}
};
let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap();
assert!(result
.wrapped_code
.contains("var Component = __ns_core.Component;"));
Expand All @@ -465,7 +500,7 @@ mod tests {
None
}
};
let result = wrap_npm_module(code, "test.js", "__ns_test", resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, resolve).unwrap();
assert!(result
.wrapped_code
.contains("Object.assign(__exports, __ns_utils)"));
Expand All @@ -474,11 +509,43 @@ mod tests {
#[test]
fn test_wrap_default_export() {
let code = "export default function helper() { return 1; }\n";
let result = wrap_npm_module(code, "test.js", "__ns_test", no_resolve).unwrap();
let result = wrap_npm_module(code, "test.js", "__ns_test", None, no_resolve).unwrap();
assert!(result.wrapped_code.contains("function helper()"));
assert!(result.wrapped_code.contains("__exports.default = helper;"));
}

#[test]
fn test_wrap_drops_unused_declaration() {
let code = "export const used = 1;\nexport const unused = 2;\n";
let mut unused: HashSet<String> = HashSet::new();
unused.insert("unused".to_string());
let result =
wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), no_resolve).unwrap();
assert!(result.wrapped_code.contains("const used = 1"));
assert!(!result.wrapped_code.contains("const unused = 2"));
assert!(result.wrapped_code.contains("__exports.used = used;"));
assert!(!result.wrapped_code.contains("__exports.unused"));
}

#[test]
fn test_wrap_drops_unused_reexport_bridge() {
let code = "export { used, unused } from './impl';\n";
let resolve = |spec: &str| -> Option<String> {
if spec == "./impl" {
Some("__ns_impl".to_string())
} else {
None
}
};
let mut unused: HashSet<String> = HashSet::new();
unused.insert("unused".to_string());
let result = wrap_npm_module(code, "test.js", "__ns_test", Some(&unused), resolve).unwrap();
assert!(result
.wrapped_code
.contains("__exports.used = __ns_impl.used"));
assert!(!result.wrapped_code.contains("__exports.unused"));
}

#[test]
fn test_namespace_from_path() {
use std::path::PathBuf;
Expand Down
45 changes: 44 additions & 1 deletion crates/bundler/src/rewrite.rs
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ pub fn rewrite_module(
None,
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
false,
)
}
Expand All @@ -83,6 +84,7 @@ pub fn rewrite_module_with_shaking(
unused_exports: Option<&HashSet<String>>,
bundled_specifiers: &HashSet<String>,
namespace_map: &HashMap<String, String>,
external_specifiers: &HashSet<String>,
preserve_exports: bool,
) -> NgcResult<RewrittenModule> {
let allocator = Allocator::new();
Expand Down Expand Up @@ -116,6 +118,7 @@ pub fn rewrite_module_with_shaking(
unused_exports,
bundled_specifiers,
namespace_map,
external_specifiers,
preserve_exports,
)
} else {
Expand Down Expand Up @@ -158,12 +161,18 @@ fn collect_module_decl_edits(
unused_exports: Option<&HashSet<String>>,
bundled_specifiers: &HashSet<String>,
namespace_map: &HashMap<String, String>,
external_specifiers: &HashSet<String>,
preserve_exports: bool,
) -> bool {
match module_decl {
ModuleDeclaration::ImportDeclaration(import) => {
let source = import.source.value.as_str();
if is_local(source, local_prefixes, bundled_specifiers) {
if is_local(
source,
local_prefixes,
bundled_specifiers,
external_specifiers,
) {
// Check if this import has a namespace mapping (npm module)
if let Some(ns) = namespace_map.get(source) {
// Replace import with namespace lookups
Expand Down Expand Up @@ -326,6 +335,7 @@ fn collect_module_decl_edits(
export.source.value.as_str(),
local_prefixes,
bundled_specifiers,
external_specifiers,
) =>
{
edits.push(TextEdit {
Expand Down Expand Up @@ -774,17 +784,46 @@ fn get_declaration_name(decl: &oxc_ast::ast::Declaration) -> Option<String> {
}

/// Check if an import specifier is local based on known prefixes or bundled specifiers.
///
/// `external_specifiers` is a *veto* set — when a specifier matches one of
/// its entries (by exact name or `<name>/...` subpath), it is treated as
/// external no matter what else would classify it. This is how
/// `angular.json`'s `externalDependencies` keeps imports like
/// `import $ from 'jquery'` from being inlined.
fn is_local(
specifier: &str,
local_prefixes: &[&str],
bundled_specifiers: &HashSet<String>,
external_specifiers: &HashSet<String>,
) -> bool {
if matches_external_specifier(specifier, external_specifiers) {
return false;
}
local_prefixes
.iter()
.any(|prefix| specifier.starts_with(prefix))
|| bundled_specifiers.contains(specifier)
}

/// Returns true when `specifier` is either an exact entry in
/// `external_specifiers` or a subpath of one (e.g. `jquery/dist/jquery.slim`
/// when `external_specifiers` lists `jquery`). Mirrors esbuild's `--external`
/// matching, which is what `@angular/build:application` uses under the hood.
pub(crate) fn matches_external_specifier(
specifier: &str,
external_specifiers: &HashSet<String>,
) -> bool {
if external_specifiers.is_empty() {
return false;
}
if external_specifiers.contains(specifier) {
return true;
}
external_specifiers
.iter()
.any(|ext| specifier.starts_with(ext) && specifier[ext.len()..].starts_with('/'))
}

/// Apply text edits to the source, producing the rewritten code.
fn apply_edits(source: &str, edits: &mut [TextEdit]) -> String {
// Sort in reverse order so later edits don't shift earlier offsets
Expand Down Expand Up @@ -1094,6 +1133,7 @@ mod tests {
Some(&unused),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
false,
)
.expect("should rewrite");
Expand Down Expand Up @@ -1128,6 +1168,7 @@ mod tests {
Some(&empty_unused),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
false,
)
.expect("should rewrite");
Expand Down Expand Up @@ -1155,6 +1196,7 @@ mod tests {
Some(&unused),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
false,
)
.expect("should rewrite");
Expand Down Expand Up @@ -1185,6 +1227,7 @@ mod tests {
Some(&empty_unused),
&HashSet::new(),
&HashMap::new(),
&HashSet::new(),
false,
)
.expect("should rewrite");
Expand Down
Loading