diff --git a/Cargo.lock b/Cargo.lock index c83a18c..1453bea 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,7 +730,7 @@ dependencies = [ [[package]] name = "ngc-bundler" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "ngc-diagnostics", @@ -755,7 +755,7 @@ dependencies = [ [[package]] name = "ngc-dev-server" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "serde_json", @@ -766,7 +766,7 @@ dependencies = [ [[package]] name = "ngc-diagnostics" -version = "0.10.10" +version = "0.10.11" dependencies = [ "serde_json", "thiserror", @@ -774,7 +774,7 @@ dependencies = [ [[package]] name = "ngc-linker" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "insta", @@ -792,7 +792,7 @@ dependencies = [ [[package]] name = "ngc-npm-resolver" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "ngc-diagnostics", @@ -807,7 +807,7 @@ dependencies = [ [[package]] name = "ngc-project-resolver" -version = "0.10.10" +version = "0.10.11" dependencies = [ "dashmap", "glob", @@ -823,7 +823,7 @@ dependencies = [ [[package]] name = "ngc-rs" -version = "0.10.10" +version = "0.10.11" dependencies = [ "base64", "clap", @@ -857,7 +857,7 @@ dependencies = [ [[package]] name = "ngc-template-compiler" -version = "0.10.10" +version = "0.10.11" dependencies = [ "insta", "ngc-diagnostics", @@ -879,7 +879,7 @@ dependencies = [ [[package]] name = "ngc-ts-transform" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "oxc_allocator", @@ -898,7 +898,7 @@ dependencies = [ [[package]] name = "ngc-watch" -version = "0.10.10" +version = "0.10.11" dependencies = [ "ngc-diagnostics", "notify", diff --git a/Cargo.toml b/Cargo.toml index cadb00f..76169bc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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.10" +version = "0.10.11" edition = "2021" license = "MIT OR Apache-2.0" authors = ["lukekania"] diff --git a/crates/cli/src/main.rs b/crates/cli/src/main.rs index 5aa980a..883e779 100644 --- a/crates/cli/src/main.rs +++ b/crates/cli/src/main.rs @@ -177,8 +177,12 @@ enum Commands { /// every `$localize\`...\`` literal in the bundled output. The /// source-locale build is moved under /// `//`. - #[arg(long)] - localize: bool, + /// + /// Pass `--localize` alone to emit every locale declared in + /// `i18n.locales`; pass `--localize=en,de` to restrict the output + /// to a subset (useful for trimming CI builds). + #[arg(long, num_args = 0..=1, value_delimiter = ',')] + localize: Option>, /// Treat any template that would fall back to JIT compilation as a /// hard error. Mirrors `@angular/build:application`, which has no /// JIT fallback. Defaults to on for `--configuration production` and @@ -203,8 +207,11 @@ enum Commands { configuration: Option, /// Emit one `//` tree per locale defined in /// `angular.json`'s `i18n.locales` block. - #[arg(long)] - localize: bool, + /// + /// Pass `--localize` alone for all locales, or `--localize=en,de` + /// to restrict the output to a subset. + #[arg(long, num_args = 0..=1, value_delimiter = ',')] + localize: Option>, }, /// Serve the project: build once, watch for changes, and host the /// resulting `dist/` directory over HTTP with live reload. Mirrors @@ -320,7 +327,7 @@ fn main() { &project, out_dir.as_deref(), configuration.as_deref(), - localize, + localize.as_deref(), Vec::new(), |_| false, ) { @@ -387,7 +394,7 @@ fn main() { &project, out_dir.as_deref(), configuration.as_deref(), - localize, + localize.as_deref(), strict_templates, ) { Ok(result) => { @@ -475,11 +482,16 @@ fn init_tracing() { } /// Orchestrate the full build pipeline: resolve → transform → bundle → output. +/// +/// `localize` mirrors the `--localize` CLI flag: `None` skips locale +/// fan-out entirely; `Some(&[])` emits every locale declared in +/// `i18n.locales`; `Some(&["en", "de"])` restricts the output to that +/// subset. fn run_build( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, strict_templates: bool, ) -> NgcResult { run_build_with_options( @@ -505,7 +517,7 @@ pub(crate) fn run_build_with_cache( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, cache: Option<&mut incremental::BuildCache>, ) -> NgcResult { run_build_with_options( @@ -533,7 +545,7 @@ pub(crate) fn run_build_with_options( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, strict_templates: bool, mut cache: Option<&mut incremental::BuildCache>, base_href_override: Option<&str>, @@ -1224,7 +1236,7 @@ pub(crate) fn run_build_with_options( // every other writer so it sees the final filenames + contents. if let Some(ref ap) = angular_project { if ap.service_worker { - if localize { + if localize.is_some() { tracing::warn!( "serviceWorker is enabled but --localize was passed; skipping ngsw.json (per-locale manifests are not yet supported)" ); @@ -1237,8 +1249,10 @@ pub(crate) fn run_build_with_options( // Step 13: --localize → fan the source-locale build out to // `//` and produce a translated copy under - // `//` for each entry in `i18n.locales`. - if localize { + // `//` for each entry in `i18n.locales`. A non-empty + // `subset` filters the emitted locales — useful for trimming CI builds + // that only need one or two locales per deploy. + if let Some(subset) = localize { let i18n = angular_project .as_ref() .and_then(|ap| ap.i18n.as_ref()) @@ -1247,7 +1261,7 @@ pub(crate) fn run_build_with_options( "--localize was passed but angular.json does not declare a `projects..i18n` block" .to_string(), })?; - let localized_files = fan_out_locales(&out_dir, i18n, &output_files)?; + let localized_files = fan_out_locales(&out_dir, i18n, subset, &output_files)?; output_files = localized_files; } @@ -1351,11 +1365,42 @@ pub(crate) fn run_build_with_options( /// Move the source-locale build under `//` and /// emit a translated copy under `//` for every entry in /// `i18n.locales`. Returns the new full set of output files. +/// +/// `subset` filters which locales are emitted. An empty slice emits every +/// locale (source plus all `i18n.locales` entries); a non-empty slice +/// restricts the output to the codes listed (validated against +/// `i18n.source_locale` and the keys of `i18n.locales`). fn fan_out_locales( out_dir: &Path, i18n: &I18nConfig, + subset: &[String], original_files: &[PathBuf], ) -> NgcResult> { + let include_source: bool; + let include_locale: Box bool>; + if subset.is_empty() { + include_source = true; + include_locale = Box::new(|_: &str| true); + } else { + // Reject `--localize=foo` when `foo` is neither the source locale + // nor one of the declared `i18n.locales` keys — silently skipping + // would let typos produce empty `dist/` runs in CI. + for code in subset { + let known = code == &i18n.source_locale || i18n.locales.contains_key(code.as_str()); + if !known { + return Err(NgcError::ConfigError { + message: format!( + "--localize subset entry `{code}` is not declared in angular.json `i18n.locales` (and is not the source locale `{}`)", + i18n.source_locale + ), + }); + } + } + include_source = subset.iter().any(|c| c == &i18n.source_locale); + let allow: std::collections::BTreeSet = subset.iter().cloned().collect(); + include_locale = Box::new(move |code: &str| allow.contains(code)); + } + // Materialize file contents from the original (source-locale) build so // we can write them back into per-locale directories without worrying // about the source-locale move clobbering them. @@ -1377,10 +1422,15 @@ fn fan_out_locales( let mut new_outputs: Vec = Vec::new(); - let source_dir = out_dir.join(&i18n.source_locale); - write_locale_tree(&source_dir, &sources, None, &mut new_outputs)?; + if include_source { + let source_dir = out_dir.join(&i18n.source_locale); + write_locale_tree(&source_dir, &sources, None, &mut new_outputs)?; + } for entry in i18n.locales.values() { + if !include_locale(entry.locale.as_str()) { + continue; + } let translations = match &entry.translation_path { Some(path) => Some(localize::parse_xliff(path)?), None => None, diff --git a/crates/cli/src/serve_cmd.rs b/crates/cli/src/serve_cmd.rs index aad8e89..7f91a77 100644 --- a/crates/cli/src/serve_cmd.rs +++ b/crates/cli/src/serve_cmd.rs @@ -74,7 +74,7 @@ pub(crate) fn run_with_stop( project, None, configuration, - false, + None, false, Some(&mut cache), normalized_serve_path.as_deref(), @@ -126,7 +126,7 @@ pub(crate) fn run_with_stop( &project_path, None, configuration_owned.as_deref(), - false, + None, false, Some(&mut cache), serve_path_owned.as_deref(), diff --git a/crates/cli/src/watch_cmd.rs b/crates/cli/src/watch_cmd.rs index bc0e208..0b5a8d4 100644 --- a/crates/cli/src/watch_cmd.rs +++ b/crates/cli/src/watch_cmd.rs @@ -25,11 +25,12 @@ pub fn run( project: &Path, out_dir_override: Option<&Path>, configuration: Option<&str>, - localize: bool, + localize: Option<&[String]>, subscribers: Vec>, should_stop: impl FnMut(usize) -> bool, ) -> NgcResult<()> { let mut cache = BuildCache::new(); + let localize_owned: Option> = localize.map(|s| s.to_vec()); // Initial build to populate the cache. `run_build_with_cache` always // disables `strict_templates` — `watch` is a dev workflow, so JIT @@ -39,7 +40,7 @@ pub fn run( project, out_dir_override, configuration, - localize, + localize_owned.as_deref(), Some(&mut cache), )?; eprintln!( @@ -76,7 +77,7 @@ pub fn run( &project_path, out_dir_path.as_deref(), configuration.as_deref(), - localize, + localize_owned.as_deref(), Some(&mut cache), )?; eprintln!( diff --git a/packages/builder/schemas/application.json b/packages/builder/schemas/application.json index 4eba804..a71efb6 100644 --- a/packages/builder/schemas/application.json +++ b/packages/builder/schemas/application.json @@ -275,7 +275,7 @@ { "type": "boolean" }, { "type": "array", "items": { "type": "string" } } ], - "description": "Generate per-locale builds. When set to true ngc-rs is invoked with `--localize` and emits one output tree per `i18n.locales` entry in angular.json. Selecting a locale subset (array form) is NOT yet honoured by ngc-rs and logs a warning." + "description": "Generate per-locale builds. When set to true ngc-rs is invoked with `--localize` and emits one output tree per `i18n.locales` entry in angular.json. The array form `[\"en\", \"de\"]` serializes as `--localize=en,de` and restricts the output to the listed locales (useful for trimming CI builds)." }, "inlineStyleLanguage": { "type": "string", diff --git a/packages/builder/src/build/__tests__/options.test.ts b/packages/builder/src/build/__tests__/options.test.ts index 300af6e..c2e2496 100644 --- a/packages/builder/src/build/__tests__/options.test.ts +++ b/packages/builder/src/build/__tests__/options.test.ts @@ -76,14 +76,25 @@ describe('translateOptions (build)', () => { expect(unset.args).not.toContain('--strict-templates'); }); - it('appends --localize and warns when localize is an array (subset not yet honoured)', () => { + it('serializes a localize array as --localize=en,de (subset)', () => { const t = translateOptions( { ...minimal, localize: ['en', 'de'] }, '/ws', null, ); + expect(t.args).toContain('--localize=en,de'); + expect(t.args).not.toContain('--localize'); + expect(t.warnings.some((w) => w.includes('locale subset'))).toBe(false); + }); + + it('treats an empty localize array as `--localize` (all locales)', () => { + const t = translateOptions( + { ...minimal, localize: [] }, + '/ws', + null, + ); expect(t.args).toContain('--localize'); - expect(t.warnings.some((w) => w.includes('locale subset'))).toBe(true); + expect(t.args.some((a) => a.startsWith('--localize='))).toBe(false); }); it('accepts non-empty scripts arrays without error', () => { diff --git a/packages/builder/src/build/options.ts b/packages/builder/src/build/options.ts index b019cea..f2c1033 100644 --- a/packages/builder/src/build/options.ts +++ b/packages/builder/src/build/options.ts @@ -175,11 +175,6 @@ export function translateOptions( 'The `outputHashing` option is hardcoded by ngc-rs per `--configuration` (production hashes bundles, development does not); the option value is ignored.', ); } - if (Array.isArray(raw.localize)) { - warnings.push( - 'Selecting a locale subset via `localize` array is not yet honoured by ngc-rs; all locales declared in `angular.json` `i18n.locales` are emitted.', - ); - } if (raw.stylePreprocessorOptions) { const opts = raw.stylePreprocessorOptions as json.JsonObject; const includePaths = opts['includePaths']; @@ -223,7 +218,6 @@ export function translateOptions( const tsConfig = raw.tsConfig ?? 'tsconfig.json'; const outDir = resolveOutDir(raw.outputPath, workspaceRoot); - const localize = raw.localize === true || Array.isArray(raw.localize); const args: string[] = ['build', '--project', tsConfig, '--output-json']; if (configuration) { @@ -232,8 +226,14 @@ export function translateOptions( if (outDir) { args.push('--out-dir', outDir); } - if (localize) { + // `localize: true` → emit all locales (`--localize` with no value). + // `localize: ['en', 'de']` → emit just that subset (`--localize=en,de`). + // `localize: []` is treated as `true` to match `@angular/build`, which + // ignores an empty array and falls back to "all locales". + if (raw.localize === true || (Array.isArray(raw.localize) && raw.localize.length === 0)) { args.push('--localize'); + } else if (Array.isArray(raw.localize)) { + args.push(`--localize=${raw.localize.join(',')}`); } if (raw.strictTemplates === true) { args.push('--strict-templates');