Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| g1t.page builds know the framework a project is built with, from its package's dependencies and config files: Vite (and React, Vue, Svelte, Solid or Preact on it), Astro, Next.js with `output: 'export'`, SvelteKit with the static adapter, Nuxt by `nuxi generate`, React Router and Remix in SPA mode, Gatsby, Docusaurus, Eleventy, Angular, Create React App, Hugo, Jekyll and mdBook are each built with their own command and served from the folder they write, read from their config where it moves it. A Cloudflare adapter's Worker deploys as a Workers project with no Wrangler config of the project's own: Astro's, which now writes its config for wrangler in `.wrangler/deploy/config.json` (as `@cloudflare/vite-plugin` does, and that is read too), SvelteKit's `.svelte-kit/cloudflare/_worker.js`, and OpenNext's with a `wrangler.jsonc`; a site's `_worker.js`, `_routes.json` and what `.assetsignore` lists are never uploaded, and a written config's empty binding lists are no longer warned about. A framework set up to need a server (Next.js without a static export, Astro's server output, SvelteKit's auto or node adapter, a server-rendered React Router or Remix app) fails before anything is installed, saying what to change, in place of "found nothing to serve". The project's Build command and Output directory still win. The build's log names the framework, the finish report carries it as `framework` (which the deployments service ignores for now), and `g1t-runner detect` and the `g1t/detect` action output it. What deploys has a table of every framework, and the detect action's README lists them. | 1 | //! Which framework a project is built with, found from its package's |
| 2 | //! dependencies and its config files, and what that means for a g1t.page | |
| 3 | //! build: the command that builds it, the folder its static site is written | |
| 4 | //! to, or the Worker a Cloudflare adapter writes. Nothing here runs anything. | |
| 5 | //! | |
| 6 | //! g1t.page serves static sites and Workers, never a server that stays up, | |
| 7 | //! so a framework set up to need one is reported with what to change. | |
| 8 | ||
| 9 | use std::collections::BTreeSet; | |
| 10 | use std::path::Path; | |
| 11 | ||
| 12 | use regex::Regex; | |
| 13 | use serde_json::{Map, Value}; | |
| 14 | ||
| 15 | /// Hugo, from the `hugo` on `PATH`, else Hugo's own release by npm. | |
| 16 | const HUGO: &str = "if command -v hugo >/dev/null 2>&1; then hugo --minify; else npx --yes hugo-extended@0.166.0 --minify; fi"; | |
| 17 | /// mdBook, from the `mdbook` on `PATH`, else built by cargo. | |
| 18 | const MDBOOK: &str = "if ! command -v mdbook >/dev/null 2>&1; then cargo install mdbook --version 0.5.4 --locked --quiet; fi && mdbook build"; | |
| 19 | /// Jekyll's gems, kept in the project rather than the system's Ruby. | |
| 20 | const BUNDLE_INSTALL: &str = "bundle config set --local path vendor/bundle && bundle install"; | |
| 21 | ||
| 22 | /// The Worker a framework's Cloudflare adapter writes, for a project with no | |
| 23 | /// Wrangler config of its own. | |
| 24 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 25 | pub(crate) struct Adapter { | |
| 26 | /// The Worker's entry, relative to the project: a file, or a directory | |
| 27 | /// holding an `index.js`. | |
| 28 | pub entry: &'static str, | |
| 29 | /// The static files served beside it. | |
| 30 | pub assets: &'static str, | |
| 31 | } | |
| 32 | ||
| 33 | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 34 | pub(crate) struct Framework { | |
| 35 | /// Short and stable, as `g1t-runner detect` prints it: `astro`, `nextjs`… | |
| 36 | pub id: &'static str, | |
| 37 | /// For the build's log. | |
| 38 | pub name: &'static str, | |
| 39 | /// What installs what the package manager does not (Jekyll's gems). | |
| 40 | pub install: Option<&'static str>, | |
| 41 | /// What builds it, unless the project gives its own command. | |
| 42 | pub build: Option<String>, | |
| 43 | /// Where it writes its static site, relative to the project. | |
| 44 | pub output: Option<String>, | |
| 45 | /// Built for Workers: with a Wrangler config, `build` runs before | |
| 46 | /// wrangler bundles it. | |
| 47 | pub workers: bool, | |
| 48 | /// Without a Wrangler config, the Worker its adapter writes. | |
| 49 | pub adapter: Option<Adapter>, | |
| 50 | /// Why it cannot be served as it is set up, and what to change. | |
| 51 | pub problem: Option<String>, | |
| 52 | } | |
| 53 | ||
| 54 | impl Framework { | |
| 55 | fn new(id: &'static str, name: &'static str) -> Self { | |
| 56 | Framework { id, name, install: None, build: None, output: None, workers: false, adapter: None, problem: None } | |
| 57 | } | |
| 58 | ||
| 59 | fn static_site(mut self, build: String, output: impl Into<String>) -> Self { | |
| 60 | self.build = Some(build); | |
| 61 | self.output = Some(output.into()); | |
| 62 | self | |
| 63 | } | |
| 64 | } | |
| 65 | ||
| 66 | /// `package.json`: what it depends on, and its scripts. | |
| 67 | struct Package { | |
| 68 | dependencies: BTreeSet<String>, | |
| 69 | scripts: Map<String, Value>, | |
| 70 | } | |
| 71 | ||
| 72 | impl Package { | |
| 73 | fn read(dir: &Path) -> Option<Package> { | |
| 74 | let text = std::fs::read_to_string(dir.join("package.json")).ok()?; | |
| 75 | let package: Value = serde_json::from_str(&text).ok()?; | |
| 76 | let dependencies = ["dependencies", "devDependencies"] | |
| 77 | .iter() | |
| 78 | .filter_map(|field| package[field].as_object()) | |
| 79 | .flat_map(|deps| deps.keys().cloned()) | |
| 80 | .collect(); | |
| 81 | let scripts = package["scripts"].as_object().cloned().unwrap_or_default(); | |
| 82 | Some(Package { dependencies, scripts }) | |
| 83 | } | |
| 84 | ||
| 85 | fn has(&self, name: &str) -> bool { | |
| 86 | self.dependencies.contains(name) | |
| 87 | } | |
| 88 | ||
| 89 | fn script(&self, name: &str) -> Option<&str> { | |
| 90 | self.scripts.get(name).and_then(Value::as_str) | |
| 91 | } | |
| 92 | ||
| 93 | /// The package's own `build` script, else the framework's command. | |
| 94 | fn build_or(&self, command: &str) -> String { | |
| 95 | if self.script("build").is_some() { "npm run build".to_owned() } else { command.to_owned() } | |
| 96 | } | |
| 97 | } | |
| 98 | ||
| 99 | /// The first of `names` in `dir`, read, without its comment lines. | |
| 100 | fn config(dir: &Path, names: &[&str]) -> String { | |
| 101 | names | |
| 102 | .iter() | |
| 103 | .find_map(|name| std::fs::read_to_string(dir.join(name)).ok()) | |
| 104 | .map(|text| { | |
| 105 | text.lines() | |
| 106 | .filter(|line| { | |
| 107 | let line = line.trim_start(); | |
| 108 | !(line.starts_with("//") || line.starts_with('*') || line.starts_with("/*") || line.starts_with('#')) | |
| 109 | }) | |
| 110 | .collect::<Vec<_>>() | |
| 111 | .join("\n") | |
| 112 | }) | |
| 113 | .unwrap_or_default() | |
| 114 | } | |
| 115 | ||
| 116 | /// Every string value `key` is given in a JavaScript or JSON config: | |
| 117 | /// `key: 'value'`, `"key": "value"`, and so on. | |
| 118 | fn values_of(text: &str, key: &str) -> Vec<String> { | |
| 119 | let pattern = format!(r#"(?:^|[^\w$.])["']?{}["']?\s*:\s*(?:"([^"]*)"|'([^']*)'|`([^`]*)`)"#, regex::escape(key)); | |
| 120 | let Ok(pattern) = Regex::new(&pattern) else { return Vec::new() }; | |
| 121 | pattern | |
| 122 | .captures_iter(text) | |
| 123 | .filter_map(|found| found.get(1).or(found.get(2)).or(found.get(3)).map(|m| m.as_str().to_owned())) | |
| 124 | .collect() | |
| 125 | } | |
| 126 | ||
| 127 | fn value_of(text: &str, key: &str) -> Option<String> { | |
| 128 | values_of(text, key).into_iter().next().map(|v| v.trim().trim_start_matches("./").trim_matches('/').to_owned()).filter(|v| !v.is_empty()) | |
| 129 | } | |
| 130 | ||
| 131 | /// Whether the config sets `key` to `value`, quoted or not (`ssr: false`). | |
| 132 | fn sets(text: &str, key: &str, value: &str) -> bool { | |
| 133 | if values_of(text, key).iter().any(|v| v == value) { | |
| 134 | return true; | |
| 135 | } | |
| 136 | let pattern = format!(r#"(?:^|[^\w$.])["']?{}["']?\s*:\s*{}\b"#, regex::escape(key), regex::escape(value)); | |
| 137 | Regex::new(&pattern).is_ok_and(|pattern| pattern.is_match(text)) | |
| 138 | } | |
| 139 | ||
| 140 | /// `key = "value"` (TOML) or `key: value` (YAML), case aside. | |
| 141 | fn setting(text: &str, key: &str) -> Option<String> { | |
| 142 | let pattern = format!(r#"(?im)^\s*{}\s*[=:]\s*["']?([^"'\s#]+)"#, regex::escape(key)); | |
| 143 | Regex::new(&pattern).ok()?.captures(text).map(|found| found[1].trim_matches('/').to_owned()) | |
| 144 | } | |
| 145 | ||
| 146 | const NEXT_CONFIGS: [&str; 4] = ["next.config.js", "next.config.mjs", "next.config.ts", "next.config.cjs"]; | |
| 147 | const ASTRO_CONFIGS: [&str; 5] = ["astro.config.mjs", "astro.config.js", "astro.config.ts", "astro.config.mts", "astro.config.cjs"]; | |
| 148 | const SVELTE_CONFIGS: [&str; 3] = ["svelte.config.js", "svelte.config.mjs", "svelte.config.ts"]; | |
| 149 | const VITE_CONFIGS: [&str; 6] = ["vite.config.ts", "vite.config.js", "vite.config.mjs", "vite.config.mts", "vite.config.cjs", "vite.config.cts"]; | |
| 150 | const REACT_ROUTER_CONFIGS: [&str; 4] = ["react-router.config.ts", "react-router.config.js", "react-router.config.mjs", "react-router.config.mts"]; | |
| 151 | const ELEVENTY_CONFIGS: [&str; 6] = | |
| 152 | ["eleventy.config.js", "eleventy.config.mjs", "eleventy.config.cjs", ".eleventy.js", ".eleventy.mjs", ".eleventy.cjs"]; | |
| 153 | ||
| 154 | /// The framework the project at `dir` is built with, if g1t knows it. | |
| 155 | /// `wrangler` is whether the project has a Wrangler config of its own. | |
| 156 | pub(crate) fn detect(dir: &Path, wrangler: bool) -> Option<Framework> { | |
| 157 | let package = Package::read(dir); | |
| 158 | if let Some(package) = &package | |
| 159 | && let Some(found) = node(dir, package, wrangler) | |
| 160 | { | |
| 161 | return Some(found); | |
| 162 | } | |
| 163 | let build_or = |command: &str| package.as_ref().map_or_else(|| command.to_owned(), |p| p.build_or(command)); | |
| 164 | for name in ["hugo.toml", "hugo.yaml", "hugo.json", "config.toml", "config.yaml"] { | |
| 165 | let Ok(text) = std::fs::read_to_string(dir.join(name)) else { continue }; | |
| 166 | // `config.toml` is also Zola's and others': Hugo's names `baseURL`, | |
| 167 | // or the folder has Hugo's layout. | |
| 168 | let hugo = name.starts_with("hugo.") | |
| 169 | || (package.is_none() | |
| 170 | && (text.to_ascii_lowercase().contains("baseurl") | |
| 171 | || ["layouts", "archetypes", "themes"].iter().any(|d| dir.join(d).is_dir()))); | |
| 172 | if hugo { | |
| 173 | let output = setting(&text, "publishDir").unwrap_or_else(|| "public".to_owned()); | |
| 174 | return Some(Framework::new("hugo", "Hugo").static_site(build_or(HUGO), output)); | |
| 175 | } | |
| 176 | } | |
| 177 | if dir.join("_config.yml").exists() && dir.join("Gemfile").exists() { | |
| 178 | let text = std::fs::read_to_string(dir.join("_config.yml")).unwrap_or_default(); | |
| 179 | let output = setting(&text, "destination").unwrap_or_else(|| "_site".to_owned()); | |
| 180 | let mut jekyll = Framework::new("jekyll", "Jekyll").static_site(build_or("bundle exec jekyll build"), output); | |
| 181 | jekyll.install = Some(BUNDLE_INSTALL); | |
| 182 | return Some(jekyll); | |
| 183 | } | |
| 184 | if let Ok(text) = std::fs::read_to_string(dir.join("book.toml")) { | |
| 185 | let output = toml::from_str::<toml::Value>(&text) | |
| 186 | .ok() | |
| 187 | .and_then(|book| book.get("build")?.get("build-dir")?.as_str().map(|d| d.trim_matches('/').to_owned())) | |
| 188 | .unwrap_or_else(|| "book".to_owned()); | |
| 189 | return Some(Framework::new("mdbook", "mdBook").static_site(build_or(MDBOOK), output)); | |
| 190 | } | |
| 191 | None | |
| 192 | } | |
| 193 | ||
| 194 | /// A framework found by the package's dependencies. | |
| 195 | fn node(dir: &Path, package: &Package, wrangler: bool) -> Option<Framework> { | |
| 196 | if package.has("next") { | |
| 197 | let mut next = Framework::new("nextjs", "Next.js"); | |
| 198 | if package.has("@opennextjs/cloudflare") { | |
| 199 | next.name = "Next.js (OpenNext's Cloudflare adapter)"; | |
| 200 | next.workers = true; | |
| 201 | next.build = Some("npx opennextjs-cloudflare build".to_owned()); | |
| 202 | if !wrangler { | |
| 203 | next.problem = Some( | |
| 204 | "This Next.js app uses OpenNext's Cloudflare adapter (`@opennextjs/cloudflare`), which deploys as a Workers project: \ | |
| 205 | add a `wrangler.jsonc` beside `package.json` with `main` set to `.open-next/worker.js` and `assets.directory` \ | |
| 206 | to `.open-next/assets`, as OpenNext's guide shows." | |
| 207 | .to_owned(), | |
| 208 | ); | |
| 209 | } | |
| 210 | } else if sets(&config(dir, &NEXT_CONFIGS), "output", "export") { | |
| 211 | next = next.static_site(package.build_or("npx next build"), "out"); | |
| 212 | } else if !wrangler { | |
| 213 | next.problem = Some( | |
| 214 | "This is a Next.js app that needs a server, and g1t.page does not run servers. Set `output: 'export'` in \ | |
| 215 | `next.config` to build it as a static site (served from `out`), or deploy it as a Worker with OpenNext's \ | |
| 216 | Cloudflare adapter, `@opennextjs/cloudflare`, and a `wrangler.jsonc`." | |
| 217 | .to_owned(), | |
| 218 | ); | |
| 219 | } | |
| 220 | return Some(next); | |
| 221 | } | |
| 222 | if package.has("astro") { | |
| 223 | let text = config(dir, &ASTRO_CONFIGS); | |
| 224 | let output = value_of(&text, "outDir").unwrap_or_else(|| "dist".to_owned()); | |
| 225 | let mut astro = Framework::new("astro", "Astro").static_site(package.build_or("npx astro build"), output); | |
| 226 | if package.has("@astrojs/cloudflare") { | |
| 227 | astro.name = "Astro (Cloudflare adapter)"; | |
| 228 | astro.workers = true; | |
| 229 | if !wrangler { | |
| 230 | astro.adapter = Some(Adapter { entry: "dist/_worker.js", assets: "dist" }); | |
| 231 | } | |
| 232 | } else if !wrangler && (sets(&text, "output", "server") || sets(&text, "output", "hybrid")) { | |
| 233 | astro.problem = Some( | |
| 234 | "This Astro site renders on a server (`output: 'server'`), and g1t.page does not run servers. Use \ | |
| 235 | `output: 'static'` (the default) to build a static site, or add the `@astrojs/cloudflare` adapter to run \ | |
| 236 | it as a Worker." | |
| 237 | .to_owned(), | |
| 238 | ); | |
| 239 | } | |
| 240 | return Some(astro); | |
| 241 | } | |
| 242 | if package.has("@sveltejs/kit") { | |
| 243 | // SvelteKit 3 takes its options in `vite.config`, 2 in `svelte.config`. | |
| 244 | let text = format!("{}\n{}", config(dir, &SVELTE_CONFIGS), config(dir, &VITE_CONFIGS)); | |
| 245 | let adapters = ["@sveltejs/adapter-static", "@sveltejs/adapter-cloudflare-workers", "@sveltejs/adapter-cloudflare"]; | |
| 246 | let adapter = adapters | |
| 247 | .iter() | |
| 248 | .find(|adapter| text.contains(*adapter)) | |
| 249 | .or_else(|| adapters.iter().find(|adapter| package.has(adapter))); | |
| 250 | let build = package.build_or("npx vite build"); | |
| 251 | let mut kit = Framework::new("sveltekit", "SvelteKit"); | |
| 252 | match adapter.copied() { | |
| 253 | Some("@sveltejs/adapter-static") => { | |
| 254 | let output = value_of(&text, "pages").unwrap_or_else(|| "build".to_owned()); | |
| 255 | kit = kit.static_site(build, output); | |
| 256 | kit.name = "SvelteKit (static adapter)"; | |
| 257 | } | |
| 258 | Some("@sveltejs/adapter-cloudflare") => { | |
| 259 | kit.name = "SvelteKit (Cloudflare adapter)"; | |
| 260 | kit.build = Some(build); | |
| 261 | kit.workers = true; | |
| 262 | if !wrangler { | |
| 263 | kit.adapter = Some(Adapter { entry: ".svelte-kit/cloudflare/_worker.js", assets: ".svelte-kit/cloudflare" }); | |
| 264 | } | |
| 265 | } | |
| 266 | Some(_) if wrangler => { | |
| 267 | kit.build = Some(build); | |
| 268 | kit.workers = true; | |
| 269 | } | |
| 270 | Some(_) => { | |
| 271 | kit.problem = Some( | |
| 272 | "`@sveltejs/adapter-cloudflare-workers` needs a `wrangler.toml`; it is deprecated, so switch to \ | |
| 273 | `@sveltejs/adapter-cloudflare` instead, which needs none." | |
| 274 | .to_owned(), | |
| 275 | ); | |
| 276 | } | |
| 277 | None if wrangler => {} | |
| 278 | None => { | |
| 279 | kit.problem = Some( | |
| 280 | "This SvelteKit app has no adapter g1t.page can serve. Use `@sveltejs/adapter-static` to build a static \ | |
| 281 | site, or `@sveltejs/adapter-cloudflare` to run it as a Worker; `adapter-auto`, `adapter-node` and the \ | |
| 282 | others need a host of their own." | |
| 283 | .to_owned(), | |
| 284 | ); | |
| 285 | } | |
| 286 | } | |
| 287 | return Some(kit); | |
| 288 | } | |
| 289 | if package.has("nuxt") { | |
| 290 | let mut nuxt = Framework::new("nuxt", "Nuxt"); | |
| 291 | if wrangler { | |
| 292 | nuxt.build = Some(package.build_or("npx nuxi build")); | |
| 293 | nuxt.workers = true; | |
| 294 | } else { | |
| 295 | // Static generation: its `generate` script, a `build` script | |
| 296 | // that generates, or `nuxi generate` itself. | |
| 297 | let build = if package.script("generate").is_some() { | |
| 298 | "npm run generate".to_owned() | |
| 299 | } else if package.script("build").is_some_and(|script| script.contains("generate")) { | |
| 300 | "npm run build".to_owned() | |
| 301 | } else { | |
| 302 | "npx nuxi generate".to_owned() | |
| 303 | }; | |
| 304 | nuxt = nuxt.static_site(build, ".output/public"); | |
| 305 | } | |
| 306 | return Some(nuxt); | |
| 307 | } | |
| 308 | for (dependency, id, name, configs, command) in [ | |
| 309 | ("@react-router/dev", "react-router", "React Router", &REACT_ROUTER_CONFIGS[..], "npx react-router build"), | |
| 310 | ("@remix-run/dev", "remix", "Remix", &VITE_CONFIGS[..], "npx remix vite:build"), | |
| 311 | ] { | |
| 312 | if !package.has(dependency) { | |
| 313 | continue; | |
| 314 | } | |
| 315 | let mut found = Framework::new(id, name); | |
| 316 | if sets(&config(dir, configs), "ssr", "false") { | |
| 317 | found = found.static_site(package.build_or(command), "build/client"); | |
| 318 | found.name = if id == "remix" { "Remix (SPA mode)" } else { "React Router (SPA mode)" }; | |
| 319 | } else if wrangler { | |
| 320 | found.build = Some(package.build_or(command)); | |
| 321 | found.workers = true; | |
| 322 | } else { | |
| 323 | let file = if id == "remix" { "the Remix plugin's options in `vite.config`" } else { "`react-router.config.ts`" }; | |
| 324 | found.problem = Some(format!( | |
| 325 | "This {name} app renders on a server, and g1t.page does not run servers. Set `ssr: false` in {file} \ | |
| 326 | to build it as a single-page app (served from `build/client`), or deploy it as a Worker with \ | |
| 327 | `@cloudflare/vite-plugin` and a `wrangler.jsonc`." | |
| 328 | )); | |
| 329 | } | |
| 330 | return Some(found); | |
| 331 | } | |
| 332 | if package.has("gatsby") { | |
| 333 | return Some(Framework::new("gatsby", "Gatsby").static_site(package.build_or("npx gatsby build"), "public")); | |
| 334 | } | |
| 335 | if package.has("@docusaurus/core") { | |
| 336 | return Some(Framework::new("docusaurus", "Docusaurus").static_site(package.build_or("npx docusaurus build"), "build")); | |
| 337 | } | |
| 338 | if package.has("@11ty/eleventy") { | |
| 339 | let output = value_of(&config(dir, &ELEVENTY_CONFIGS), "output").unwrap_or_else(|| "_site".to_owned()); | |
| 340 | return Some(Framework::new("eleventy", "Eleventy").static_site(package.build_or("npx @11ty/eleventy"), output)); | |
| 341 | } | |
| 342 | if package.has("@angular/core") && dir.join("angular.json").exists() { | |
| 343 | let mut angular = Framework::new("angular", "Angular"); | |
| 344 | angular.build = Some(package.build_or("npx ng build")); | |
| 345 | angular.output = angular_output(dir); | |
| 346 | return Some(angular); | |
| 347 | } | |
| 348 | if package.has("react-scripts") { | |
| 349 | return Some( | |
| 350 | Framework::new("create-react-app", "Create React App").static_site(package.build_or("npx react-scripts build"), "build"), | |
| 351 | ); | |
| 352 | } | |
| 353 | let vite_config = VITE_CONFIGS.iter().any(|name| dir.join(name).exists()); | |
| 354 | if package.has("vite") || vite_config { | |
| 355 | let (id, name) = [ | |
| 356 | ("@vitejs/plugin-react", "vite-react", "React (Vite)"), | |
| 357 | ("@vitejs/plugin-react-swc", "vite-react", "React (Vite)"), | |
| 358 | ("@vitejs/plugin-vue", "vite-vue", "Vue (Vite)"), | |
| 359 | ("@sveltejs/vite-plugin-svelte", "vite-svelte", "Svelte (Vite)"), | |
| 360 | ("vite-plugin-solid", "vite-solid", "Solid (Vite)"), | |
| 361 | ("@preact/preset-vite", "vite-preact", "Preact (Vite)"), | |
| 362 | ] | |
| 363 | .iter() | |
| 364 | .find(|(plugin, ..)| package.has(plugin)) | |
| 365 | .map_or(("vite", "Vite"), |(_, id, name)| (*id, *name)); | |
| 366 | let build = package.build_or("npx vite build"); | |
| 367 | if wrangler && package.has("@cloudflare/vite-plugin") { | |
| 368 | let mut vite = Framework::new(id, name); | |
| 369 | vite.build = Some(build); | |
| 370 | vite.workers = true; | |
| 371 | return Some(vite); | |
| 372 | } | |
| 373 | let output = value_of(&config(dir, &VITE_CONFIGS), "outDir").unwrap_or_else(|| "dist".to_owned()); | |
| 374 | return Some(Framework::new(id, name).static_site(build, output)); | |
| 375 | } | |
| 376 | None | |
| 377 | } | |
| 378 | ||
| 379 | /// Where `ng build` writes the app's browser files: the application | |
| 380 | /// builder's `<outputPath>/browser`, or the older builders' `<outputPath>`. | |
| 381 | fn angular_output(dir: &Path) -> Option<String> { | |
| 382 | let text = std::fs::read_to_string(dir.join("angular.json")).ok()?; | |
| 383 | let workspace: Value = serde_json::from_str(&text).ok()?; | |
| 384 | let projects = workspace["projects"].as_object()?; | |
| 385 | let (name, project) = workspace["defaultProject"] | |
| 386 | .as_str() | |
| 387 | .and_then(|name| projects.get_key_value(name)) | |
| 388 | .or_else(|| projects.iter().find(|(_, project)| project["projectType"] == "application")) | |
| 389 | .or_else(|| projects.iter().next())?; | |
| 390 | let targets = if project["architect"].is_object() { &project["architect"] } else { &project["targets"] }; | |
| 391 | let build = &targets["build"]; | |
| 392 | let builder = build["builder"].as_str().unwrap_or(""); | |
| 393 | let application = builder.is_empty() || builder.ends_with(":application"); | |
| 394 | let trim = |path: &str| path.trim().trim_start_matches("./").trim_matches('/').to_owned(); | |
| 395 | Some(match &build["options"]["outputPath"] { | |
| 396 | Value::String(path) if application => format!("{}/browser", trim(path)), | |
| 397 | Value::String(path) => trim(path), | |
| 398 | Value::Object(output) => { | |
| 399 | let base = output.get("base").and_then(Value::as_str).map_or_else(|| format!("dist/{name}"), trim); | |
| 400 | match output.get("browser").and_then(Value::as_str).map(trim) { | |
| 401 | Some(browser) if browser.is_empty() => base, | |
| 402 | Some(browser) => format!("{base}/{browser}"), | |
| 403 | None => format!("{base}/browser"), | |
| 404 | } | |
| 405 | } | |
| 406 | _ if application => format!("dist/{name}/browser"), | |
| 407 | _ => format!("dist/{name}"), | |
| 408 | }) | |
| 409 | } | |
| 410 | ||
| 411 | #[cfg(test)] | |
| 412 | mod tests { | |
| 413 | use super::*; | |
| 414 | ||
| 415 | #[test] | |
| 416 | fn config_values_are_read_quoted_or_not() { | |
| 417 | let text = "export default defineConfig({\n output: 'export',\n build: { \"outDir\": \"site\" },\n ssr: false,\n})"; | |
| 418 | assert!(sets(text, "output", "export")); | |
| 419 | assert!(!sets(text, "output", "server")); | |
| 420 | assert!(sets(text, "ssr", "false")); | |
| 421 | assert_eq!(value_of(text, "outDir").as_deref(), Some("site")); | |
| 422 | // Another key that ends the same is not it. | |
| 423 | assert!(!sets("{ distOutput: 'export' }", "output", "export")); | |
| 424 | assert_eq!(setting("baseURL = 'https://x'\npublishDir = \"site/\"", "publishDir").as_deref(), Some("site")); | |
| 425 | } | |
| 426 | } |