| 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 | + | } |